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.
390 lines
15 KiB
C++
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");
|
|
}
|