sots-engine/tests/game_sim/test_techgraph.cpp
Alex 405ba41a1e U: the SetResearched unlock cascade, and the prediction for the run that checks it
Pure module game/sim/techgraph: PrereqsMet (AND of ORs, empty group fails),
SetResearched (stamps, child-cost sweep, sticky turnAvailable, zero-cost
recursion) and the newly-available collector, all read out of 0x00581e10,
0x0057d8e0 and 0x00587cc3.

Wired into the B3 hook in compare mode only, over the scratch node copies:
four more node write-backs, the EVENT_TECHS_UNLOCKED list (still an input,
still nullptr when it could not be computed), the de-duplicating observed-tech
append and the one RNG word RollResearchEvent draws.

docs/U-unlock.md section 4 is the prediction, written before the build was staged.
2026-09-08 06:26:00 -04:00

390 lines
15 KiB
C++

// 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 <string>
#include <vector>
#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<int>(i);
}
g.hasOwner = true;
g.turn = turn;
return g;
}
void link(TechGraph& g, int parent, int child, int costRP) {
g.nodes[static_cast<std::size_t>(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<int> freeNodes;
int costCalls = 0;
std::vector<int> researchedCallbacks;
std::vector<bool> silentSeen;
static int Cost(void* ctx, int i) {
Env& e = *static_cast<Env*>(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<Env*>(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<int>(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<int>(TechState::Available);
CHECK(!TechPrereqsMet(g, 0));
// Groups are an AND: every one must be satisfied.
g.nodes[1].state = static_cast<int>(TechState::Researched);
g.nodes[0].prereqs.groups = {{1}, {2}};
CHECK(!TechPrereqsMet(g, 0));
g.nodes[2].state = static_cast<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(TechState::Available));
CHECK_EQ(g.nodes[2].state, static_cast<int>(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<int> 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<int>(TechState::ParentResearched);
g.turn = 9;
TechGraph g2 = g;
g2.nodes[0].state = static_cast<int>(TechState::CurrentTarget);
Env e2;
SetResearched(g2, 0, kTechForce, e2.env());
CHECK_EQ(g2.nodes[1].state, static_cast<int>(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<int>(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<int>(TechState::Researched));
CHECK_EQ(g.nodes[1].order, 11);
CHECK_EQ(g.nodes[2].state, static_cast<int>(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<int> 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<int>(TechState::Researched));
CHECK_EQ(g.nodes[1].state, static_cast<int>(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<int>(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<int>(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<int>(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<int>(TechState::Available));
const std::vector<int> 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<int>(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<std::size_t>(i)].state, static_cast<int>(TechState::Available));
CHECK_EQ(g.nodes[static_cast<std::size_t>(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");
}