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.
This commit is contained in:
alex 2026-09-08 15:39:42 -04:00
parent 4065808a05
commit e30d43dd1b
9 changed files with 558 additions and 25 deletions

View file

@ -126,3 +126,49 @@ regressed 0; the phase reports the condition and posts nothing.
Run B is the control for F1: it is the same code with the input withheld, and its over-fire
count is a direct measurement of how much of the phase the AI roster is carrying.
---
## 4. Measured
`tools/standalone_report.py --binary <this build> --engine-arg=...`, all four runs against
the same binary. `closed` and `regressed` are never netted.
| run | pair 1 (209 baseline) | closed | regressed | pair 2 (108 baseline) | closed | regressed |
|---|---:|---:|---:|---:|---:|---:|
| before this lane | 131 | 78 | 0 | 72 | 36 | 0 |
| **A: `--data` + roster + `--commit-blocked=P11`** | **127** | **82** | **0** | **69** | **39** | **0** |
| B: nothing supplied (default) | 131 | 78 | 0 | 72 | 36 | 0 |
| F1 control: `--data`, **no roster** | 139 | 86 | **16** | 69 | 39 | 0 |
| F2 control: roster, **no `--data`** | 131 | 78 | 0 | 72 | 36 | 0 |
**The lane closes 4 leaves on pair 1 and 3 on pair 2, regressed 0.** They are exactly the
four and three paths the prediction named, and no other leaf moved. The prediction held.
Two things the run said that the prediction did not:
* **The oracle's record matches ours field for field.** The bucket exists in both saves after
run A, so the checksum descends into it, and nothing under it is reported — `EvEID`,
`EvDsc`, `EvMsg`, `EvImg`, `EvLoc`, all three `EvPos` words, `EvAct` and `EvCID` all agree.
The text came out of the operator's own string table through the key lookup, so F3 and F4
are not merely untriggered, they are actively disconfirmed.
* **F1's regression is 16, not the ~15 estimated, and it lands on pair 1 only.** Pair 2's
input save already carries a research target for all three AI players, so `ResTNm` alone
suppresses them there and the roster buys nothing. The AI roster is load-bearing exactly on
the turn after which the AI *first* picks a target — which is the reference pair, and which
is why the phase cannot be committed without it.
## 5. What is still not posted, and what blocks it
| event | player | pairs | blocker |
|---|---|---|---|
| `EVENT_SHIPS_BUILT` | 1 | both | a ship the turn builds. Both reference pairs carry **no build order anywhere** — three empty system queues, no ship-borne queue, and the only command block is the human's empty one — so the order behind that ship is generated by the AI during the turn. Same blocker `B6` and `T36` already name: **AI order generation**, not this phase. |
| `EVENT_RESEARCH_OVERBUDGET` | 1 | pair 2 | the research allocation, which needs `ComputeBudget`'s per-system money on the turn path (roadmap item 1). `game/events` already holds the posting rule and `game/sim` the over-budget decision; only the points are missing. |
| tail event phases `T18` / `T21` / `T32` | — | neither | **measured negative**: no `EVENT_ENEMY_INCOMING_*`, `EVENT_FLEET_EXPLORED` or `EVENT_FLEET_ARRIVED` appears in either oracle save, so these phases post nothing on the reference pair. Other corpus saves do carry all three, so they are live phases with an unexercised workload here, not dead ones. |
Two tail functions that sound as though they should be in this table are not, and lane K's
reading is why: the one named for generating a turn's events is 122 bytes whose copy loop is
provably dead, and the one named for building them is gated on a descriptor set only on the
load/rejoin path and references no event identifier at all. **No tail phase manufactures the
turn's events.** Every event a turn posts is posted by the subsystem that caused it — the
research pass, the build pass, movement, combat — which is what section 1 measures.

View file

@ -10,6 +10,7 @@ add_library(sots_app STATIC
treaty.cpp
turn_record.cpp
construction_phase.cpp
event_phase.cpp
visibility_phase.cpp
turn.cpp
report.cpp)
@ -18,7 +19,7 @@ target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..)
# needs a design's hull size and defence-platform flag, which are recomputed from the section
# catalog and are nowhere on the wire. No game data is embedded; the root is supplied at run
# time and its absence costs exactly six fields.
target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim game_design)
target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim sots_game_events game_design)
target_compile_features(sots_app PUBLIC cxx_std_17)
if(NOT MSVC)
target_compile_options(sots_app PRIVATE -Wall -Wextra -Werror)

106
src/app/event_phase.cpp Normal file
View file

@ -0,0 +1,106 @@
#include "app/event_phase.h"
#include <utility>
namespace sots::app {
using mars::stream::shapes::EventRec;
using mars::stream::shapes::Player;
std::string_view EventTextTable::Lookup(void* ctx, std::string_view key) {
auto* self = static_cast<EventTextTable*>(ctx);
if (!self || !self->strings_) return {};
if (auto v = self->strings_->get(std::string(key))) return *v;
return {};
}
events::EventStorage LoadEventStorage(const mars::stream::shapes::EventStorage& wire) {
events::EventStorage log;
log.set_nextId(wire.evNxID);
for (const auto& b : wire.turns) {
events::TurnEvents bucket;
bucket.turn = b.evTurn;
for (const auto& e : b.events) {
events::PlayerEvent ev;
ev.id = e.evEID;
ev.summary = e.evDsc;
ev.message = e.evMsg;
ev.image = e.evImg;
ev.location = e.evLoc;
ev.pos.x = e.evPos.x;
ev.pos.y = e.evPos.y;
ev.pos.z = e.evPos.z;
ev.action = e.evAct;
ev.chainId = e.evCID;
bucket.events.push_back(std::move(ev));
}
log.turns().push_back(std::move(bucket));
}
return log;
}
int StoreEventStorage(const events::EventStorage& log,
mars::stream::shapes::EventStorage& wire) {
int leaves = 0;
if (wire.evNxID != log.nextId()) {
wire.evNxID = log.nextId();
++leaves;
}
const std::size_t bucketsBefore = wire.turns.size();
if (bucketsBefore != log.turns().size()) ++leaves; // the collection's count leaf
wire.turns.resize(log.turns().size());
for (std::size_t i = 0; i < log.turns().size(); ++i) {
const events::TurnEvents& src = log.turns()[i];
auto& dst = wire.turns[i];
if (i >= bucketsBefore) ++leaves; // a bucket the phase added
const std::size_t eventsBefore = dst.events.size();
dst.evTurn = src.turn;
dst.events.resize(src.events.size());
for (std::size_t k = 0; k < src.events.size(); ++k) {
const events::PlayerEvent& e = src.events[k];
EventRec& r = dst.events[k];
if (k >= eventsBefore) ++leaves; // a record the phase added
r.evEID = e.id;
r.evDsc = e.summary;
r.evMsg = e.message;
r.evImg = e.image;
r.evLoc = e.location;
r.evPos.x = e.pos.x;
r.evPos.y = e.pos.y;
r.evPos.z = e.pos.z;
r.evAct = e.action;
r.evCID = e.chainId;
}
}
return leaves;
}
NoResearchDecision DecideNoResearchEvent(const Player& p, int turn, bool isAI) {
NoResearchDecision d;
d.noTarget = p.resTNm.empty();
d.aiWouldPick = isAI;
// State codes on a tech row: 0 hidden, 1 parent researched, 2 available, 3 available and
// selected as the current target, 4 researched.
constexpr std::int32_t kStateAvailable = 2;
constexpr std::int32_t kStateResearched = 4;
bool researchedThisTurn = false;
for (const auto& st : p.techTree.state) {
if (st.st == kStateAvailable) d.anyAvailable = true;
if (st.st == kStateResearched && st.tAcq >= turn) researchedThisTurn = true;
}
d.noneResearched = !researchedThisTurn;
return d;
}
NoResearchPost PostNoResearchEvent(Player& p, int turn, const events::EventText& text) {
NoResearchPost out;
events::EventStorage log = LoadEventStorage(p.events);
out.eventId = events::PostNoResearch(log, text, turn);
out.leafWrites = StoreEventStorage(log, p.events);
return out;
}
} // namespace sots::app

92
src/app/event_phase.h Normal file
View file

@ -0,0 +1,92 @@
// The standalone's bridge between the save's event subtree and `game::events`.
//
// `game/events` is pure: it knows the posting rules, the per-bucket dedup, the FLT_MAX
// position sentinel and the `EvNxID` sequence, and it knows nothing about the wire or about
// the game's displayed text. This file is where the standalone connects it to both:
//
// * `LoadEventStorage` / `StoreEventStorage` move a player's log between the save's
// `mars::stream::shapes::EventStorage` and the engine's `events::EventStorage`;
// * `EventTextTable` turns the operator's own installed string table into the
// `events::EventText` lookup the posting helpers take. No displayed text is compiled in
// -- the engine holds `EVENTSUM_*` / `EVENTMSG_*` keys and nothing else, and a run with
// no `--data` root resolves every key to the empty string and posts nothing.
//
// See docs/EV-events.md for what a turn actually posts and for the condition this phase
// evaluates.
#pragma once
#include <string>
#include <string_view>
#include <vector>
#include "game/data/strings.h"
#include "game/events/event_log.h"
#include "game/events/research_events.h"
#include "mars/stream/shapes.h"
namespace sots::app {
// A `game::data::StringTable` seen as an `events::EventText`. Holding the table by pointer
// keeps the engine free of it; a null table is a legal state and yields empty text, which is
// what the game's own uninitialised string slots hold.
class EventTextTable {
public:
explicit EventTextTable(const ::game::data::StringTable* strings) : strings_(strings) {}
bool available() const { return strings_ != nullptr && !strings_->empty(); }
events::EventText text() const { return events::EventText{&Lookup, const_cast<EventTextTable*>(this)}; }
private:
static std::string_view Lookup(void* ctx, std::string_view key);
const ::game::data::StringTable* strings_ = nullptr;
};
// Wire -> engine. The two are the same shape; the copy exists so the posting rules operate
// on the engine's own type and never on the parse tree.
events::EventStorage LoadEventStorage(const mars::stream::shapes::EventStorage& wire);
// Engine -> wire. Returns the number of save leaves the write moved, counted the way every
// other phase counts: one for `EvNxID` when it changed, one for the bucket-collection count
// when it changed, one per bucket added and one per event record added. Records that already
// existed are rewritten identically and are not counted.
int StoreEventStorage(const events::EventStorage& log, mars::stream::shapes::EventStorage& wire);
// ---------------------------------------------------------------------------------------
// P11 PostNoResearchEvent
// ---------------------------------------------------------------------------------------
// The three tests the original applies, split so the run log can say which one refused.
//
// noTarget the player holds no research target. On the wire that is an empty
// `ResTNm`, which is the target the save was WRITTEN with -- see
// `aiWouldPick` below for what that misses.
// noneResearched no tech row is in state 4 (researched) with `TAcq >= turn`, i.e. nothing
// finished on this turn or later. This is the test that keeps the event off
// the turn a tech lands, and `zuul-turn23-fleet23.sav` exercises it.
// anyAvailable at least one tech row is in state 2 (available). This is what excludes
// the four monster factions, whose whole tree is state 4.
// aiWouldPick the operator named this player as AI-controlled. HYPOTHESIS: an AI player
// that starts its turn with no target acquires one before this check, so it
// never posts. The missing input is AI research selection (`game/ai`); the
// roster stands in for it. See docs/EV-events.md section 2.
struct NoResearchDecision {
bool noTarget = false;
bool noneResearched = false;
bool anyAvailable = false;
bool aiWouldPick = false;
bool fires() const { return noTarget && noneResearched && anyAvailable && !aiWouldPick; }
};
NoResearchDecision DecideNoResearchEvent(const mars::stream::shapes::Player& p, int turn,
bool isAI);
struct NoResearchPost {
int eventId = 0; // the id Post returned; 0 means nothing was posted
int leafWrites = 0;
};
// Post the event into `p.events`. The caller has already decided that it should fire and that
// a text table is available; this only does the posting and the write-back.
NoResearchPost PostNoResearchEvent(mars::stream::shapes::Player& p, int turn,
const events::EventText& text);
} // namespace sots::app

View file

@ -164,14 +164,26 @@ int main(int argc, char** argv) {
game::data::Catalog catalog;
if (!dataDir.empty()) {
catalog = game::data::load_catalog(dataDir);
if (catalog.sections.empty()) {
std::printf("data: %s holds no ship sections; the ship census stays unmodelled\n",
// Two phases read this root and they need different parts of it: the tail's ship
// census needs the section catalog, and the no-research event needs the string
// table. A root that yields one but not the other is still worth taking, so the
// acceptance test is "either", and the line below says which arrived.
if (catalog.sections.empty() && !catalog.strings_loaded) {
std::printf("data: %s holds neither ship sections nor a string table; the ship "
"census and the event text both stay unmodelled\n",
dataDir.c_str());
} else {
opt.catalog = &catalog;
std::printf("data: %s -- %zu section(s) over %zu race(s), %zu load problem(s)\n",
std::printf("data: %s -- %zu section(s) over %zu race(s), %zu string(s), "
"%zu load problem(s)\n",
dataDir.c_str(), catalog.sections.size(), catalog.races.size(),
catalog.problems.size());
catalog.strings.size(), catalog.problems.size());
if (catalog.sections.empty())
std::printf("data: no ship sections in that root; the ship census stays "
"unmodelled\n");
if (!catalog.strings_loaded)
std::printf("data: no Locale/EN/Strings.csv in that root; the event text "
"stays unmodelled\n");
}
} else {
std::printf("data: no root given (--data DIR / $SOTS_DATA_DIR); the tail's ship census "

View file

@ -181,8 +181,21 @@ constexpr PhaseDesc kPlayer[] = {
"fires is counted into the RNG ledger but the generator state is only written back under "
"--commit-rng, because the turn's other draws are not yet attributed"},
{Driver::Player, 11, "P11", "PostNoResearchEvent", PhaseStatus::Blocked,
"the condition is implemented and reported; posting needs the localised event text table "
"and the event-id sequence, neither of which the standalone has"},
"posting IS wired now: the event goes into the player's own turn bucket through "
"game::events, with the id sequence, the per-bucket dedup, the FLT_MAX position sentinel "
"and EvAct=1. Three of the original's four inputs are computed from the wire -- no "
"research target, nothing researched on this turn or later (the test that keeps the "
"event off the turn a tech lands), and at least one available tech, which is what "
"excludes the monster factions whose whole tree is researched. Two things keep it "
"blocked and neither is the posting. First the displayed text: the engine carries only "
"the EVENTSUM_/EVENTMSG_ keys, so a run without a data root resolves them to nothing and "
"the phase refuses to write a record it cannot fill. Second the target: the save's "
"ResTNm is the target the FILE was written with, and three of the four real players on "
"the reference pair acquire one during the turn -- that is AI research selection, and "
"--ai-player N stands in for it as a stated hypothesis. Measured with "
"--commit-blocked=P11 --data ROOT --ai-player 1 --ai-player 2 --ai-player 3: 4 leaves "
"closed on turn1->turn2 and 3 on turn2->turn3, 0 regressed. Without the roster the phase "
"over-fires by three players; that run is the control and it is in docs/EV-events.md"},
{Driver::Player, 12, "P12", "PruneRaidTargets", PhaseStatus::Stub,
"20-turn ageing of the raid-target list; the records are opaque on the wire"},
};

View file

@ -9,6 +9,7 @@
#include "app/alliance.h"
#include "app/construction_phase.h"
#include "app/event_phase.h"
#include "app/trade_raid.h"
#include "app/treaty.h"
#include "app/turn_record.h"
@ -96,6 +97,12 @@ struct PlayerPhaseTotals {
int wouldWrite[13] = {};
int rng[13] = {};
int fired[13] = {}; // how many players the phase actually did something for
// P11's own accounting, kept apart from the generic counters so the run log can say why
// the phase did or did not post rather than only how many leaves it moved.
int evalNoResearch = 0; // players passing the three save-derivable tests
int aiSuppressedNoResearch = 0; // of those, the ones the AI roster suppressed
int postedNoResearch = 0; // events actually posted
int noTextNoResearch = 0; // would have posted but no string table was supplied
};
// What the caller has to hand the player driver for P01: the per-system money of every
@ -115,8 +122,20 @@ struct PlayerBudgetFeed {
int turnIncome = 0;
};
// What P11 needs beyond the player itself: the turn to post into (the frame AFTER H00's
// bump), whether the operator declared this player AI-controlled, and the text lookup. A
// null `text` means no string table was supplied, which is not the same as "no event": the
// phase still evaluates its condition and reports it.
struct PlayerEventFeed {
int turn = 0;
bool isAI = false;
const EventTextTable* text = nullptr;
bool commit = false;
};
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
PlayerPhaseTotals& t, const PlayerBudgetFeed& feed) {
PlayerPhaseTotals& t, const PlayerBudgetFeed& feed,
const PlayerEventFeed& events) {
// --- P01 ComputeBudget ------------------------------------------------------------
// The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the
// turn path runs the system's own sliders, so the build queue, the ship-repair pass and
@ -220,17 +239,32 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
}
}
// --- P11 PostNoResearchEvent -- condition only ----------------------------------
if (p.resTNm.empty()) {
bool anyAvailable = false;
for (const auto& st : p.techTree.state)
if (st.st == 2) {
anyAvailable = true;
break;
}
if (anyAvailable) {
// --- P11 PostNoResearchEvent ----------------------------------------------------
// Three tests, not the two this used to apply. The one that was missing -- "no tech
// finished on this turn or later" -- is what keeps the event off the turn a tech lands,
// and it is exercised by the corpus. The fourth term, `aiWouldPick`, is the stand-in for
// AI research selection: the save's `ResTNm` is the target the file was written with, and
// three of the four real players on the reference pair acquire one mid-turn. See
// app/event_phase.h and docs/EV-events.md.
{
const NoResearchDecision d =
DecideNoResearchEvent(p, events.turn, events.isAI);
if (d.noTarget && d.noneResearched && d.anyAvailable) ++t.evalNoResearch;
if (d.aiWouldPick && d.noTarget && d.noneResearched && d.anyAvailable)
++t.aiSuppressedNoResearch;
if (d.fires()) {
++t.fired[11];
++t.wouldWrite[11];
const bool haveText = events.text && events.text->available();
if (!haveText) ++t.noTextNoResearch;
if (haveText && events.commit) {
const NoResearchPost post =
PostNoResearchEvent(p, events.turn, events.text->text());
t.writes[11] += post.leafWrites;
++t.postedNoResearch;
} else {
// One `EvNxID`, one collection count, one bucket, one record.
t.wouldWrite[11] += 4;
}
}
}
}
@ -837,6 +871,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
const TurnRecordAudit recordAudit =
AuditTurnRecordsAgainstSave(game, opt.catalog ? &inputCensus : nullptr);
// The event text. The engine holds keys; the text comes from the operator's own installed
// string table, which arrives with the data root or not at all.
const EventTextTable eventText(opt.catalog && opt.catalog->strings_loaded
? &opt.catalog->strings
: nullptr);
mars::rng::MT19937 gen(1u);
r.rngLoaded = LoadGenerator(game, gen);
if (!r.rngLoaded)
@ -964,15 +1004,23 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
int fed = 0, agree = 0, worst = 0;
for (auto& e : game.sim.players) {
const PlayerBudgetFeed& f = feeds[fi++];
// The event bucket a post lands in is the turn AFTER H00's bump, which
// is `sim.frame` by the time this driver runs. Measured: a turn run from
// a save at turn N posts into bucket N+1 in every corpus save.
PlayerEventFeed ef;
ef.turn = game.sim.frame;
ef.isAI = opt.IsAIPlayer(e.player.plyrIdx);
ef.text = &eventText;
ef.commit = opt.CommitBlocked("P11");
if (!f.systemIncome.empty()) ++fed;
else {
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f, ef);
continue;
}
const int d = f.turnIncome - f.projectedIncome;
if (d == 0) ++agree;
if (d > worst || -d > worst) worst = d < 0 ? -d : d;
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f, ef);
}
rec.notes.push_back(fmt(
"%d player(s) had a non-empty per-system money roll-up on the TURN path "
@ -1079,10 +1127,32 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
pr.notes.push_back(fmt("%d player(s) held both a target and the pending "
"flag; %d roll(s) fired",
pt.fired[10], pt.writes[10]));
if (idx == 11 && pt.fired[11])
pr.notes.push_back(fmt("%d player(s) meet the no-research condition; the "
"event is not posted",
pt.fired[11]));
if (idx == 11) {
pr.notes.push_back(fmt(
"%d player(s) pass the three save-derivable tests (no target, nothing "
"researched this turn or later, at least one available tech); the AI "
"roster suppressed %d of them",
pt.evalNoResearch, pt.aiSuppressedNoResearch));
if (pt.postedNoResearch)
pr.notes.push_back(fmt("%d event(s) posted into bucket EvTurn=%d",
pt.postedNoResearch, game.sim.frame));
if (pt.noTextNoResearch)
pr.notes.push_back(fmt(
"%d event(s) NOT posted: no string table. The engine carries the "
"EVENTSUM_/EVENTMSG_ keys and never the text, so a run without "
"--data (or $SOTS_DATA_DIR) cannot write a record the oracle "
"would match, and writing an empty one would regress it",
pt.noTextNoResearch));
if (pt.aiSuppressedNoResearch)
pr.notes.push_back(
"HYPOTHESIS: an AI-controlled player with no research target "
"acquires one before this check, so it never posts. The real "
"input is AI research selection; --ai-player N stands in for it. "
"With no roster supplied the phase over-fires by exactly the "
"number of AI empires that pick a target this turn");
if (!opt.CommitBlocked("P11") && pt.fired[11])
pr.notes.push_back("evaluated, not committed: pass --commit-blocked=P11");
}
r.records.push_back(pr);
}
}

View file

@ -29,13 +29,20 @@ add_executable(app_test_treaty test_treaty.cpp)
target_link_libraries(app_test_treaty PRIVATE sots_app)
add_test(NAME app_treaty COMMAND app_test_treaty)
# The no-research event, as a rule. Pins what the corpus cannot separate -- the
# researched-this-turn test, the state-2 reading, the FLT_MAX sentinel, EvAct=1 and the id
# sequence from a fresh EvNxID -- so it always runs.
add_executable(app_test_event_phase test_event_phase.cpp)
target_link_libraries(app_test_event_phase PRIVATE sots_app)
add_test(NAME app_event_phase COMMAND app_test_event_phase)
# The turn-record model against the record the game itself archived; needs the owner's saves.
add_executable(app_test_turn_record test_turn_record.cpp)
target_link_libraries(app_test_turn_record PRIVATE sots_app)
add_test(NAME app_turn_record COMMAND app_test_turn_record)
foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance
app_test_treaty app_test_turn_record)
app_test_treaty app_test_event_phase app_test_turn_record)
target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic)
endforeach()

View file

@ -0,0 +1,186 @@
// 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;
}