// TechTree::SetResearched's unlock cascade, the prerequisite test and the newly-available // collector. Every case here is a property read out of the instruction stream, not a guess about // what a tech tree "should" do -- the four that are easiest to get wrong are the sticky // turnAvailable, the signed costRP minimum against an INT_MAX sentinel, the empty prerequisite // group, and the self-resolving index both sweeps go through. #include "game/sim/techgraph.h" #include #include #include "check.h" using namespace sots::sim; namespace { // A tree of `n` nodes, every slot present, every node resolving to itself, all hidden. TechGraph make_tree(std::size_t n, int turn = 5) { TechGraph g; g.nodes.resize(n); for (std::size_t i = 0; i < n; ++i) { g.nodes[i].present = true; g.nodes[i].selfIndex = static_cast(i); } g.hasOwner = true; g.turn = turn; return g; } void link(TechGraph& g, int parent, int child, int costRP) { g.nodes[static_cast(parent)].children.push_back(TechEdgeRef{child, costRP}); } // Counts how many times the cost hook and the owner callback were reached, and lets a test make // a chosen node free so the recursive branch fires. struct Env { std::vector freeNodes; int costCalls = 0; std::vector researchedCallbacks; std::vector silentSeen; static int Cost(void* ctx, int i) { Env& e = *static_cast(ctx); ++e.costCalls; for (int f : e.freeNodes) if (f == i) return 0; return 1000; } static void OnResearched(void* ctx, int i, bool silent) { Env& e = *static_cast(ctx); e.researchedCallbacks.push_back(i); e.silentSeen.push_back(silent); } TechCascadeEnv env() { TechCascadeEnv v; v.cost = &Cost; v.onResearched = &OnResearched; v.ctx = this; return v; } }; // --------------------------------------------------------------------------------------- void test_prereqs() { TechGraph g = make_tree(5); // No groups at all: satisfied. The original returns `count == count` with both zero. CHECK(TechPrereqsMet(g, 0)); // One group, one tech, not researched. g.nodes[0].prereqs.groups = {{3}}; CHECK(!TechPrereqsMet(g, 0)); g.nodes[3].state = static_cast(TechState::Researched); CHECK(TechPrereqsMet(g, 0)); // A group is an OR: any one member satisfies it. g.nodes[0].prereqs.groups = {{1, 2, 3}}; CHECK(TechPrereqsMet(g, 0)); g.nodes[3].state = static_cast(TechState::Available); CHECK(!TechPrereqsMet(g, 0)); // Groups are an AND: every one must be satisfied. g.nodes[1].state = static_cast(TechState::Researched); g.nodes[0].prereqs.groups = {{1}, {2}}; CHECK(!TechPrereqsMet(g, 0)); g.nodes[2].state = static_cast(TechState::Researched); CHECK(TechPrereqsMet(g, 0)); // An EMPTY group fails the whole test -- the inner loop cannot break, so the outer one // breaks with the group uncounted. This is the case a "vacuously true" reading gets wrong. g.nodes[0].prereqs.groups = {{1}, {}}; CHECK(!TechPrereqsMet(g, 0)); // A listed tech whose node the tree does not hold satisfies nothing. g.nodes[0].prereqs.groups = {{4}}; g.nodes[4].present = false; g.nodes[4].state = static_cast(TechState::Researched); CHECK(!TechPrereqsMet(g, 0)); // An unreadable structure is "not met", never "met by default". g.nodes[0].prereqs.groups.clear(); g.nodes[0].prereqs.unreadable = true; CHECK(!TechPrereqsMet(g, 0)); } void test_completion_stamps() { TechGraph g = make_tree(3, /*turn=*/7); g.orderCounter = 22; g.nodes[0].state = static_cast(TechState::CurrentTarget); Env e; const TechCascadeResult r = SetResearched(g, 0, kTechForce, e.env()); CHECK(r.ran); CHECK(!r.alreadyResearched); CHECK_EQ(g.nodes[0].state, static_cast(TechState::Researched)); CHECK_EQ(g.nodes[0].turnResearched, 7); CHECK_EQ(g.nodes[0].order, 22); CHECK_EQ(g.orderCounter, 23); // ProcessResearch passes flags = 2, so bit 2 is clear and the callback is NOT silent -- // which is what makes the completion event fire. CHECK_EQ(e.researchedCallbacks.size(), std::size_t{1}); CHECK(!e.silentSeen[0]); // Re-completing writes nothing at all: not the order stamp, not the counter. Env e2; const TechCascadeResult again = SetResearched(g, 0, kTechForce, e2.env()); CHECK(again.alreadyResearched); CHECK(again.ran); CHECK_EQ(g.nodes[0].order, 22); CHECK_EQ(g.orderCounter, 23); CHECK_EQ(e2.researchedCallbacks.size(), std::size_t{0}); // With no owner the turn stamp is a literal 0, not the turn. TechGraph h = make_tree(1, /*turn=*/7); h.hasOwner = false; Env e3; SetResearched(h, 0, kTechForce, e3.env()); CHECK_EQ(h.nodes[0].turnResearched, 0); CHECK_EQ(e3.researchedCallbacks.size(), std::size_t{0}); // no owner, no callback } void test_force_and_prereq_gate() { TechGraph g = make_tree(2); g.nodes[0].prereqs.groups = {{1}}; // node 1 is not researched Env e; // Unforced, the gate holds and nothing is written. CHECK(!SetResearched(g, 0, 0u, e.env()).ran); CHECK_EQ(g.nodes[0].state, static_cast(TechState::Hidden)); CHECK_EQ(g.orderCounter, 0); // ProcessResearch's flags = 2 skips the gate: the roll has already been won. CHECK(SetResearched(g, 0, kTechForce, e.env()).ran); CHECK_EQ(g.nodes[0].state, static_cast(TechState::Researched)); // An absent node is a no-op, not a completion. TechGraph h = make_tree(1); h.nodes[0].present = false; Env e2; const TechCascadeResult r = SetResearched(h, 0, kTechForce, e2.env()); CHECK(!r.ran); CHECK(!r.alreadyResearched); } void test_sweep1_costs_and_states() { TechGraph g = make_tree(4, /*turn=*/4); link(g, 0, 1, 10000); link(g, 0, 2, 16000); g.nodes[3].state = static_cast(TechState::Available); // untouched by this cascade g.nodes[3].turnAvailable = 2; Env e; SetResearched(g, 0, kTechForce, e.env()); // Hidden children become ParentResearched, then the second sweep makes them Available and // stamps this turn. CHECK_EQ(g.nodes[1].costRP, 10000); CHECK_EQ(g.nodes[2].costRP, 16000); CHECK_EQ(g.nodes[1].state, static_cast(TechState::Available)); CHECK_EQ(g.nodes[2].state, static_cast(TechState::Available)); CHECK_EQ(g.nodes[1].turnAvailable, 4); CHECK_EQ(g.nodes[2].turnAvailable, 4); // A node that was already available keeps its original availability turn. CHECK_EQ(g.nodes[3].turnAvailable, 2); // The collector sees the two new ones and not the old one. const std::vector unlocked = CollectNewlyAvailable(g, 4); CHECK_EQ(unlocked.size(), std::size_t{2}); CHECK_EQ(unlocked[0], 1); CHECK_EQ(unlocked[1], 2); CHECK_EQ(CollectNewlyAvailable(g, 2).size(), std::size_t{1}); } void test_cost_minimum_is_signed_and_monotone() { // Two parents reach the same child at different costs; the sentinel is INT_MAX, the compare // is signed, so the FIRST researched parent sets the cost and later parents only lower it. TechGraph g = make_tree(3); link(g, 0, 2, 8000); link(g, 1, 2, 12000); Env e; CHECK_EQ(g.nodes[2].costRP, kNoResearchCost); SetResearched(g, 1, kTechForce, e.env()); CHECK_EQ(g.nodes[2].costRP, 12000); SetResearched(g, 0, kTechForce, e.env()); CHECK_EQ(g.nodes[2].costRP, 8000); // cheaper: lowered // And the expensive parent cannot raise it back. TechGraph h = make_tree(3); link(h, 0, 2, 8000); link(h, 1, 2, 12000); Env e2; SetResearched(h, 0, kTechForce, e2.env()); SetResearched(h, 1, kTechForce, e2.env()); CHECK_EQ(h.nodes[2].costRP, 8000); } void test_turn_available_is_sticky() { TechGraph g = make_tree(2, /*turn=*/3); link(g, 0, 1, 500); Env e; SetResearched(g, 0, kTechForce, e.env()); CHECK_EQ(g.nodes[1].turnAvailable, 3); // Push the child back to ParentResearched and re-run the cascade on a later turn. The state // returns to Available but turnAvailable keeps its first value, so the collector does NOT // re-announce it. This is the property that makes EVENT_TECHS_UNLOCKED fire once per tech. g.nodes[1].state = static_cast(TechState::ParentResearched); g.turn = 9; TechGraph g2 = g; g2.nodes[0].state = static_cast(TechState::CurrentTarget); Env e2; SetResearched(g2, 0, kTechForce, e2.env()); CHECK_EQ(g2.nodes[1].state, static_cast(TechState::Available)); CHECK_EQ(g2.nodes[1].turnAvailable, 3); CHECK_EQ(CollectNewlyAvailable(g2, 9).size(), std::size_t{0}); } void test_excluded_byte_blocks_the_sweep() { TechGraph g = make_tree(2, /*turn=*/6); link(g, 0, 1, 500); g.nodes[1].excludedFromSweep = true; Env e; SetResearched(g, 0, kTechForce, e.env()); // Sweep 1 still runs on it -- the exclusion byte is only tested in sweep 2 -- so the state // and the cost move, but it never becomes Available and never stamps a turn. CHECK_EQ(g.nodes[1].costRP, 500); CHECK_EQ(g.nodes[1].state, static_cast(TechState::ParentResearched)); CHECK_EQ(g.nodes[1].turnAvailable, -1); CHECK_EQ(CollectNewlyAvailable(g, 6).size(), std::size_t{0}); } void test_free_child_completes_recursively() { TechGraph g = make_tree(3, /*turn=*/5); g.orderCounter = 10; link(g, 0, 1, 0); link(g, 1, 2, 700); Env e; e.freeNodes = {1}; const TechCascadeResult r = SetResearched(g, 0, kTechForce, e.env()); // Node 1 became available at zero cost, so the sweep researched it, and ITS cascade unlocked // node 2 in the same call. CHECK_EQ(r.completed.size(), std::size_t{2}); CHECK_EQ(r.completed[0], 0); CHECK_EQ(r.completed[1], 1); CHECK_EQ(g.nodes[1].state, static_cast(TechState::Researched)); CHECK_EQ(g.nodes[1].order, 11); CHECK_EQ(g.nodes[2].state, static_cast(TechState::Available)); CHECK_EQ(g.nodes[2].costRP, 700); CHECK_EQ(e.researchedCallbacks.size(), std::size_t{2}); // The free node never appears in the unlocked list: it left state 2 before the collector ran. const std::vector unlocked = CollectNewlyAvailable(g, 5); CHECK_EQ(unlocked.size(), std::size_t{1}); CHECK_EQ(unlocked[0], 2); CHECK(!r.depthExceeded); } void test_zero_cost_cycle_terminates() { // A zero-cost cycle does NOT hang: the state-4 early return is what makes the recursion // well founded, and it is the only thing that does. Worth pinning, because the depth cap // below would otherwise look like the reason. TechGraph g = make_tree(2); link(g, 0, 1, 0); link(g, 1, 0, 0); Env e; e.freeNodes = {0, 1}; const TechCascadeResult r = SetResearched(g, 0, kTechForce, e.env()); CHECK(!r.depthExceeded); CHECK_EQ(r.completed.size(), std::size_t{2}); CHECK_EQ(g.nodes[0].state, static_cast(TechState::Researched)); CHECK_EQ(g.nodes[1].state, static_cast(TechState::Researched)); } void test_recursion_is_capped_not_hung() { // A long enough chain of free techs recurses once per link. The original has no guard at // all; a reimplementation running inside the game must report rather than blow the stack. TechGraph g = make_tree(10); for (int i = 0; i + 1 < 10; ++i) link(g, i, i + 1, 0); Env e; for (int i = 0; i < 10; ++i) e.freeNodes.push_back(i); TechCascadeEnv env = e.env(); env.maxDepth = 4; const TechCascadeResult r = SetResearched(g, 0, kTechForce, env); CHECK(r.depthExceeded); // With the cap lifted the same tree completes the whole chain. TechGraph h = make_tree(10); for (int i = 0; i + 1 < 10; ++i) link(h, i, i + 1, 0); Env e2; for (int i = 0; i < 10; ++i) e2.freeNodes.push_back(i); const TechCascadeResult r2 = SetResearched(h, 0, kTechForce, e2.env()); CHECK(!r2.depthExceeded); CHECK_EQ(r2.completed.size(), std::size_t{10}); } void test_self_index_indirection() { // Both sweeps and the collector test the state of `nodes[node->def->techId]`, not of the // node they are iterating. A tree where those differ behaves accordingly; this pins that the // indirection is reproduced rather than shortcut. TechGraph g = make_tree(3, /*turn=*/8); link(g, 0, 1, 400); g.nodes[1].selfIndex = 2; // node 1 resolves to node 2 g.nodes[2].state = static_cast(TechState::ParentResearched); g.nodes[2].excludedFromSweep = true; // so the sweep cannot promote node 2 on its own pass Env e; SetResearched(g, 0, kTechForce, e.env()); // Node 1's own state went to ParentResearched via sweep 1, but sweep 2 tests node 2's state // (which IS ParentResearched), so it is node 1 that is written to Available. CHECK_EQ(g.nodes[1].state, static_cast(TechState::Available)); CHECK_EQ(g.nodes[1].turnAvailable, 8); // ...and the collector then tests node 2's state again, which is still 1, not 2, so node 1 // is not collected even though node 1 itself is available. CHECK_EQ(g.nodes[2].state, static_cast(TechState::ParentResearched)); CHECK_EQ(CollectNewlyAvailable(g, 8).size(), std::size_t{0}); } void test_absent_slots_are_skipped() { TechGraph g = make_tree(4, /*turn=*/2); link(g, 0, 1, 100); link(g, 0, 3, 200); g.nodes[1].present = false; // a tech this species' tree does not hold Env e; SetResearched(g, 0, kTechForce, e.env()); CHECK_EQ(g.nodes[3].state, static_cast(TechState::Available)); const std::vector unlocked = CollectNewlyAvailable(g, 2); CHECK_EQ(unlocked.size(), std::size_t{1}); CHECK_EQ(unlocked[0], 3); } // The shape lane V measured live on 2026-09-08: one completion unlocking three children, whose // costs come from the edges and whose availability turn is the completion turn. Reproduced here // as a regression pin on the numbers that appeared in the compare report. void test_live_shape_call3() { TechGraph g = make_tree(150, /*turn=*/4); g.orderCounter = 22; g.nodes[144].state = static_cast(TechState::CurrentTarget); link(g, 144, 132, 10000); link(g, 144, 136, 16000); link(g, 144, 142, 8000); Env e; SetResearched(g, 144, kTechForce, e.env()); CHECK_EQ(g.nodes[144].order, 22); CHECK_EQ(g.nodes[144].turnResearched, 4); CHECK_EQ(g.nodes[132].costRP, 10000); CHECK_EQ(g.nodes[136].costRP, 16000); CHECK_EQ(g.nodes[142].costRP, 8000); for (int i : {132, 136, 142}) { CHECK_EQ(g.nodes[static_cast(i)].state, static_cast(TechState::Available)); CHECK_EQ(g.nodes[static_cast(i)].turnAvailable, 4); } CHECK_EQ(CollectNewlyAvailable(g, 4).size(), std::size_t{3}); } } // namespace int main() { test_prereqs(); test_completion_stamps(); test_force_and_prereq_gate(); test_sweep1_costs_and_states(); test_cost_minimum_is_signed_and_monotone(); test_turn_available_is_sticky(); test_excluded_byte_blocks_the_sweep(); test_free_child_completes_recursively(); test_zero_cost_cycle_terminates(); test_recursion_is_capped_not_hung(); test_self_index_indirection(); test_absent_slots_are_skipped(); test_live_shape_call3(); return simtest::finish("game_sim_techgraph"); }