sots-engine/tests/app/test_event_phase.cpp
alex e30d43dd1b P11: post the no-research event into the save's turn bucket
The engine has modelled the event log since lane E, but the standalone never wrote
any of it into the save it produces. This wires the two together for the one event
on the reference pair the standalone can compute, and corrects the condition.

What a turn actually posts, measured over all eleven saves: two events on
turn1 -> turn2 and three on turn2 -> turn3, and only two players in the whole
corpus ever hold an event at all. Order is readable off the ids -- the build pass
posts before the research pass. See docs/EV-events.md section 1.

The condition had two of the original's three tests. The missing one is "no tech
finished on this turn or later", and it is what keeps the event off the turn a tech
lands; zuul-turn23 exercises it. The third input, whether the player holds a target
at the moment of the check, is not on the wire -- three of the four real players
acquire one during the turn, which is AI research selection -- so the operator's
--ai-player roster stands in for it as a stated hypothesis and the phase stays
blocked without it.

No prose in the engine: the record's text is resolved through a caller-supplied
lookup over the operator's own installed string table, and a run without a data
root posts nothing rather than writing a record it cannot fill.

Measured (CT111 host build, real data root), closed and regressed never netted:
  turn1 -> turn2  209 -> 127  closed 82 (+4 this lane), regressed 0
  turn2 -> turn3  108 ->  69  closed 39 (+3 this lane), regressed 0
The posted record agrees with the oracle on all eight of its fields. Controls: with
the roster withheld the phase over-fires and regresses 16 leaves on pair 1; with the
string table withheld it posts nothing and regresses none.

Gates run separately: tools/clean_room_check.sh OK; host ctest 50/50; CT111 shim
cross-build OK (exports 66 names identical to binkw32.dll, staged in
/srv/re-lab/shim/dist-ev); CT111 host ctest 50/50.
2026-09-08 15:39:42 -04:00

186 lines
8.2 KiB
C++

// The no-research event, pinned as a rule rather than as a table of observations.
//
// The corpus agrees with this rule wherever it can be seen: the human posts the event on
// every turn it holds no research target, and does NOT post it on the turn a tech of its own
// completed. That comparison lives against the owner's saves. What is pinned HERE is what a
// save cannot separate:
//
// * the "nothing researched on this turn or later" test. Only one corpus save exercises it
// (a turn on which a tech landed), and a build that dropped the test would still look
// right on both reference pairs, where nothing is researched at all.
// * the available-tech test reads state 2 specifically. A build that accepted "not all
// researched" would agree on every corpus player except the monster factions.
// * the position sentinel is FLT_MAX, not infinity, and the action is stored as 1 rather
// than falling into the `act == 0` rule.
// * the bucket is created at the posting turn and the id sequence starts at 1 from a fresh
// `EvNxID` of 0, which the wire cannot distinguish from "never posted" after the fact.
// * the wire round trip is lossless, so a phase that posts nothing writes nothing.
#include <cfloat>
#include <cstdio>
#include <string>
#include "app/event_phase.h"
static int failures = 0;
#define CHECK_EQ(a, b) \
do { \
const long long va = (long long)(a), vb = (long long)(b); \
if (va != vb) { \
std::printf("FAIL %s:%d %s == %s (%lld != %lld)\n", __FILE__, \
__LINE__, #a, #b, va, vb); \
++failures; \
} \
} while (0)
#define CHECK_STR(a, b) \
do { \
const std::string va = (a), vb = (b); \
if (va != vb) { \
std::printf("FAIL %s:%d %s == %s (\"%s\" != \"%s\")\n", __FILE__, \
__LINE__, #a, #b, va.c_str(), vb.c_str()); \
++failures; \
} \
} while (0)
using mars::stream::shapes::Player;
using mars::stream::shapes::TechState;
using sots::app::DecideNoResearchEvent;
using sots::app::LoadEventStorage;
using sots::app::PostNoResearchEvent;
using sots::app::StoreEventStorage;
namespace {
// The two keys the phase resolves, with stand-in text. No shipped prose in this repo: the
// point of the lookup is that the text is the caller's, so the test supplies its own.
std::string_view TestLookup(void*, std::string_view key) {
if (key == "EVENTSUM_NO_RESEARCH") return "SUM";
if (key == "EVENTMSG_NO_RESEARCH") return "MSG";
return {};
}
sots::events::EventText TestText() { return sots::events::EventText{&TestLookup, nullptr}; }
TechState Row(const char* name, int state, int turnAcquired) {
TechState s;
s.tNm = name;
s.st = state;
s.tAcq = turnAcquired;
return s;
}
} // namespace
int main() {
// --- the decision -------------------------------------------------------------------
{
Player p;
p.techTree.state.push_back(Row("A", 4, 1)); // researched, on an earlier turn
p.techTree.state.push_back(Row("B", 2, 0)); // available
const auto d = DecideNoResearchEvent(p, 2, /*isAI=*/false);
CHECK_EQ(d.noTarget, true);
CHECK_EQ(d.noneResearched, true);
CHECK_EQ(d.anyAvailable, true);
CHECK_EQ(d.fires(), true);
}
{
// A tech that landed THIS turn suppresses the event. This is the test the reference
// pairs cannot exercise.
Player p;
p.techTree.state.push_back(Row("A", 4, 2));
p.techTree.state.push_back(Row("B", 2, 0));
const auto d = DecideNoResearchEvent(p, 2, false);
CHECK_EQ(d.noneResearched, false);
CHECK_EQ(d.fires(), false);
// ... and a tech researched on a LATER turn than the one being posted suppresses it
// too: the original's range runs to INT_MAX, not to the turn.
p.techTree.state[0].tAcq = 9;
CHECK_EQ(DecideNoResearchEvent(p, 2, false).noneResearched, false);
}
{
// The monster factions: every row researched, none available.
Player p;
p.techTree.state.push_back(Row("A", 4, 1));
p.techTree.state.push_back(Row("B", 4, 1));
const auto d = DecideNoResearchEvent(p, 2, false);
CHECK_EQ(d.anyAvailable, false);
CHECK_EQ(d.fires(), false);
}
{
// A row in state 1 (parent researched) or 3 (selected) is not "available".
Player p;
p.techTree.state.push_back(Row("A", 1, 0));
p.techTree.state.push_back(Row("B", 3, 0));
CHECK_EQ(DecideNoResearchEvent(p, 2, false).anyAvailable, false);
}
{
Player p;
p.resTNm = "IND_Waldo";
p.techTree.state.push_back(Row("B", 2, 0));
CHECK_EQ(DecideNoResearchEvent(p, 2, false).noTarget, false);
CHECK_EQ(DecideNoResearchEvent(p, 2, false).fires(), false);
}
{
// The AI stand-in suppresses a player that would otherwise post.
Player p;
p.techTree.state.push_back(Row("B", 2, 0));
const auto d = DecideNoResearchEvent(p, 2, /*isAI=*/true);
CHECK_EQ(d.noTarget, true);
CHECK_EQ(d.anyAvailable, true);
CHECK_EQ(d.aiWouldPick, true);
CHECK_EQ(d.fires(), false);
}
// --- the post -----------------------------------------------------------------------
{
Player p;
const auto post = PostNoResearchEvent(p, 2, TestText());
CHECK_EQ(post.eventId, 1);
CHECK_EQ(post.leafWrites, 4); // EvNxID, the collection count, the bucket, the record
CHECK_EQ(p.events.evNxID, 2); // 0 -> 1 on the first post, then id = nextId++
CHECK_EQ(p.events.turns.size(), 1u);
CHECK_EQ(p.events.turns[0].evTurn, 2);
CHECK_EQ(p.events.turns[0].events.size(), 1u);
const auto& r = p.events.turns[0].events[0];
CHECK_EQ(r.evEID, 1);
CHECK_STR(r.evDsc, "SUM");
CHECK_STR(r.evMsg, "MSG");
CHECK_STR(r.evImg, "EVENT_NO_RESEARCH");
CHECK_EQ(r.evLoc, 0);
CHECK_EQ(r.evAct, 1); // NOT 2: the `act == 0` rule must not fire here
CHECK_EQ(r.evCID, 0);
CHECK_EQ(r.evPos.x == FLT_MAX, true);
CHECK_EQ(r.evPos.y == FLT_MAX, true);
CHECK_EQ(r.evPos.z == FLT_MAX, true);
// A second turn appends a bucket and continues the id sequence.
const auto next = PostNoResearchEvent(p, 3, TestText());
CHECK_EQ(next.eventId, 2);
CHECK_EQ(next.leafWrites, 4);
CHECK_EQ(p.events.evNxID, 3);
CHECK_EQ(p.events.turns.size(), 2u);
CHECK_EQ(p.events.turns[1].evTurn, 3);
// Posting the same event twice into the same bucket is a dedup: the id comes back
// and neither the list nor EvNxID moves.
const auto dup = PostNoResearchEvent(p, 3, TestText());
CHECK_EQ(dup.eventId, 2);
CHECK_EQ(dup.leafWrites, 0);
CHECK_EQ(p.events.evNxID, 3);
CHECK_EQ(p.events.turns.size(), 2u);
}
// --- the wire round trip ------------------------------------------------------------
{
Player p;
PostNoResearchEvent(p, 2, TestText());
const auto before = p.events;
const int moved = StoreEventStorage(LoadEventStorage(p.events), p.events);
CHECK_EQ(moved, 0);
CHECK_EQ(p.events.evNxID, before.evNxID);
CHECK_EQ(p.events.turns.size(), before.turns.size());
CHECK_STR(p.events.turns[0].events[0].evImg, before.turns[0].events[0].evImg);
}
if (failures == 0) std::printf("app_event_phase: OK\n");
return failures ? 1 : 0;
}