From 586171cb72f51b25505ed4dada6df2b4b0904deb Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 9 Sep 2026 10:15:12 -0400 Subject: [PATCH] app: T34 RecordObservedDesigns -- the observed-design list, and the three rules the save cannot show The tail's phase 34 sweeps every player over every ship of every fleet and records the design of each ship it can see. Three of the recorder's rules are invisible from a save and every one of them changes the answer: * the lookup key is the DESIGN ID ALONE, not the (design, owner) pair; * a re-observation ERASES the record and PUSHES A COPY ON THE BACK rather than updating in place, so the list ends in last-observation order and the record's first-seen turn survives the move. This is why a turn's output is a PERMUTATION of its input and not an append -- which is what made the group look like 55 unrelated leaves; * the list is CAPPED AT 20 RECORDS PER DESIGN OWNER, counted from the most recent end, and the cap is applied after EVERY record call rather than once per sweep. On an empire with more than twenty designs in service that thrashes inside the one sweep: records are evicted and re-created, and a re-created record's first-seen turn is reset. A fourth rule is a guard on the DESIGN'S OWNER and not on the observer: a design owned by an NPC that is not a rebel AI is never observed by anyone. Every observation record in every corpus save agrees -- the NPC factions' 26 designs appear in nobody's list -- and removing the guard costs 9 leaves on the canonical pair while costing nothing on the rich turn, so the two pairs test different rules and both were run. TWO THINGS ARE NOT MODELLED AND BOTH ARE NAMED IN THE CATALOG TEXT. The gate is a two-bit-per-player word on the ship that is NOT serialised; this phase stands it in with ownership, which is the smallest rule correct on every state the corpus holds, because on both reference pairs no player observes another player's ship. That is a hypothesis with a cheap falsifier and it is labelled as one. And the phase's tech and weapon arms are not implemented: the original feeds the design through three set builders into the observed-tech and observed-weapon lists, and those builders are not decoded. Measured, closed and regressed never netted: canonical pair turn2 -> turn3 62 -> 61 leaves 1 closed / 0 regressed rich turn ad-turn27 -> pinB 1092 -> 1058 36 closed / 2 regressed The 2 are two `otnF` words at positions whose `odid` also moves, i.e. positions that now hold a different record; and the 21 design leaves that remain are the shadow of ship construction, not of this phase -- two designs receive their first ships during that turn and no phase here builds a ship. Fed the true post-turn ship list, the same code leaves 1 leaf of 55. Host build 255/255; host ctest 57/59 with the two pre-existing corpus-writer failures unchanged (12 of 43 saves, the SpecialProjectNameGen `usp` item, not this lane's); shim cross-build green; clean-room and shim-config checks green. --- src/app/CMakeLists.txt | 1 + src/app/observed_phase.cpp | 172 +++++++++++++++++++++++++++++++ src/app/observed_phase.h | 47 +++++++++ src/app/phase_catalog.cpp | 30 +++++- src/app/turn.cpp | 7 ++ src/game/sim/CMakeLists.txt | 3 +- src/game/sim/observed.cpp | 55 ++++++++++ src/game/sim/observed.h | 83 +++++++++++++++ tests/game_sim/test_observed.cpp | 158 ++++++++++++++++++++++++++++ 9 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 src/app/observed_phase.cpp create mode 100644 src/app/observed_phase.h create mode 100644 src/game/sim/observed.cpp create mode 100644 src/game/sim/observed.h create mode 100644 tests/game_sim/test_observed.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 029b97b..12a9c6a 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(sots_app STATIC construction_phase.cpp event_phase.cpp growth_phase.cpp + observed_phase.cpp script_phase.cpp visibility_phase.cpp command_replay.cpp diff --git a/src/app/observed_phase.cpp b/src/app/observed_phase.cpp new file mode 100644 index 0000000..b10af15 --- /dev/null +++ b/src/app/observed_phase.cpp @@ -0,0 +1,172 @@ +#include "app/observed_phase.h" + +#include +#include +#include + +#include "game/sim/observed.h" + +namespace sots::app { +namespace { + +using mars::stream::shapes::Odes; +using mars::stream::shapes::SaveGame; + +std::string fmt(const char* f, ...) { + char buf[512]; + va_list ap; + va_start(ap, f); + std::vsnprintf(buf, sizeof buf, f, ap); + va_end(ap); + return std::string(buf); +} + +// Which player owns which design, and whether that owner's designs can be observed at all. +struct DesignOwner { + std::int32_t ownerId = 0; + bool observable = false; +}; + +std::unordered_map DesignOwners(const SaveGame& game) { + std::unordered_map out; + for (const auto& e : game.sim.players) { + const bool observable = + sim::DesignOwnerIsObservable(e.player.npc, e.player.rebAI); + for (const auto& d : e.player.designs) { + DesignOwner o; + o.ownerId = e.playerID; + o.observable = observable; + out[d.desID] = o; + } + // A legacy design is still a design the game can hand a ship; treat it the same, but + // never let it shadow a live one. + for (const auto& d : e.player.legacyDesigns) { + if (out.count(d.desID)) continue; + DesignOwner o; + o.ownerId = e.playerID; + o.observable = observable; + out[d.desID] = o; + } + } + return out; +} + +std::vector FromWire(const std::vector& in) { + std::vector out; + out.reserve(in.size()); + for (const auto& o : in) { + sim::ObservedDesign r; + r.turnFirst = o.ontF; + r.turnLast = o.otnL; + r.designId = o.odid; + r.ownerId = o.opid; + out.push_back(r); + } + return out; +} + +// Leaves that move, counted the way state_checksum counts them: the array's own count leaf +// when the length changes, then one per differing field of each surviving position, then the +// whole record for a position only one side has. +int LeafDelta(const std::vector& before, const std::vector& after) { + int moved = 0; + if (before.size() != after.size()) ++moved; + const std::size_t common = before.size() < after.size() ? before.size() : after.size(); + for (std::size_t i = 0; i < common; ++i) { + if (before[i].ontF != after[i].turnFirst) ++moved; + if (before[i].otnL != after[i].turnLast) ++moved; + if (before[i].odid != after[i].designId) ++moved; + if (before[i].opid != after[i].ownerId) ++moved; + } + moved += static_cast((before.size() > after.size() ? before.size() - after.size() + : after.size() - before.size())) * 4; + return moved; +} + +} // namespace + +ObservedDesignsResult RunRecordObservedDesigns(SaveGame& game) { + ObservedDesignsResult r; + const std::unordered_map owners = DesignOwners(game); + const std::int32_t turn = game.sim.frame; + + int unknownDesigns = 0; + for (auto& pe : game.sim.players) { + if (!sim::ObserverSlotVisited(pe.player.plyrIdx)) { + ++r.playersSkipped; + continue; + } + ++r.playersSwept; + + std::vector list = FromWire(pe.player.odes); + + for (const auto& fe : game.sim.fleets) { + for (const auto& se : fe.flt.ships) { + // The stand-in for the ship's unserialised per-player visibility word. + if (se.ship.plrID != pe.playerID) continue; + ++r.shipsOffered; + + const auto it = owners.find(se.ship.desID); + if (it == owners.end()) { + ++unknownDesigns; + continue; + } + if (!it->second.observable) { + ++r.npcDesignsSkipped; + continue; + } + ++r.recordCalls; + const sim::RecordObservedDesignResult rec = + sim::RecordObservedDesign(list, se.ship.desID, it->second.ownerId, turn); + if (rec.created) ++r.created; + if (rec.moved) ++r.moved; + r.evicted += rec.evicted; + } + } + + const int moved = LeafDelta(pe.player.odes, list); + if (moved == 0) continue; + + std::vector out; + out.reserve(list.size()); + for (std::size_t i = 0; i < list.size(); ++i) { + // Carry the existing element's opaque tail where there is one, so a record the + // save wrote with extra items round-trips; a created record has none. + Odes o; + if (i < pe.player.odes.size()) o.extra = pe.player.odes[i].extra; + o.ontF = list[i].turnFirst; + o.otnL = list[i].turnLast; + o.odid = list[i].designId; + o.opid = list[i].ownerId; + out.push_back(o); + } + pe.player.odes = out; + ++r.playersTouched; + r.leafWrites += moved; + } + + r.notes.push_back(fmt("%d player(s) swept, %d skipped (slot >= %d); %d (player, ship) " + "pair(s) offered, %d recorded", + r.playersSwept, r.playersSkipped, sim::kObserverSlotLimit, + r.shipsOffered, r.recordCalls)); + r.notes.push_back(fmt("%d record(s) created, %d moved to the back, %d evicted by the " + "%d-per-owner cap, over %d player(s); %d leaf/leaves", + r.created, r.moved, r.evicted, sim::kObservedDesignsPerOwner, + r.playersTouched, r.leafWrites)); + if (r.npcDesignsSkipped) + r.notes.push_back(fmt("%d ship(s) carry a design owned by a non-rebel NPC, which is " + "never observed by anyone -- the guard is on the DESIGN'S " + "OWNER, not on the observer", + r.npcDesignsSkipped)); + if (unknownDesigns) + r.notes.push_back(fmt("%d ship(s) name a design no player's list carries; skipped, " + "because the record needs the design's owner", + unknownDesigns)); + r.notes.push_back("the gate is a HYPOTHESIS: the original tests a two-bit-per-player word " + "on the ship that is NOT serialised, and this phase stands it in with " + "ownership. No save in the corpus separates the two -- on both reference " + "pairs every observation that moves is a player's own design"); + return r; +} + +} // namespace sots::app diff --git a/src/app/observed_phase.h b/src/app/observed_phase.h new file mode 100644 index 0000000..6ec9f8d --- /dev/null +++ b/src/app/observed_phase.h @@ -0,0 +1,47 @@ +// T34 RecordObservedDesigns, wired to the save shapes. +// +// The original's sweep is `for each player, for each fleet, for each ship of that fleet`, +// with one gate per (player, ship) pair, and it calls the recorder once per SHIP -- not once +// per design. The repetition is load-bearing: the recorder moves a re-observed record to the +// back of the list, so the final order is the order of each design's LAST ship in the sweep, +// and the per-owner cap is applied after every call, so a player with more than twenty +// designs in service evicts and re-creates records inside the one sweep. +// +// THE GATE IS NOT ON THE WIRE, and this is the single hypothesis in the phase. The original +// tests a two-bit-per-player field on the ship (index 2*playerSlot, both bits required); the +// ship's serialised field list does not carry that word. This phase stands it in with +// OWNERSHIP -- a player is offered its own ships and nothing else -- which is the smallest +// rule that is correct on every state the corpus contains: on both reference pairs no player +// observes another player's ship, so no save can separate the two rules. The falsifier is +// named and cheap: the first save in which one empire's fleet stands in another's sensor +// range AND a foreign observation record moves. +// +// What IS read from the binary, and what makes this phase more than a guess, is in +// `game/sim/observed.h`: the dedup key, the erase-and-re-append, and the 20-per-owner cap. +#pragma once + +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +struct ObservedDesignsResult { + int playersSwept = 0; // players the sweep visited (slot < 15) + int playersSkipped = 0; // players whose slot is at or above the sweep's limit + int shipsOffered = 0; // (player, ship) pairs that passed the gate + int recordCalls = 0; // calls into the recorder -- one per offered ship + int created = 0; // records appended for a design not previously in the list + int moved = 0; // records erased and re-appended + int evicted = 0; // records the per-owner cap dropped + int npcDesignsSkipped = 0; // ships whose design belongs to a non-rebel NPC + int leafWrites = 0; // save leaves this phase changed + int playersTouched = 0; + std::vector notes; +}; + +// T34: refresh every player's observed-design list from the ships it can see. +ObservedDesignsResult RunRecordObservedDesigns(mars::stream::shapes::SaveGame& game); + +} // namespace sots::app diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 824c46f..7245bd6 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -333,7 +333,35 @@ constexpr PhaseDesc kTail[] = { "is a fact only a running process could supply"}, {Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""}, - {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""}, + {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Partial, + "refreshes every player's observed-DESIGN list from the ships it can see. The sweep is " + "for each player, over every ship of every fleet -- once per SHIP, not once per design " + "-- and three of the recorder's rules are invisible from the save and all three change " + "the answer: the lookup key is the design id ALONE, not the (design, owner) pair; a " + "re-observation ERASES the record and PUSHES A COPY ON THE BACK rather than updating it " + "in place, so the list ends up in last-observation order and the record's first-seen " + "turn survives the move; and the list is CAPPED AT 20 RECORDS PER DESIGN OWNER, counted " + "from the most recent end, with the cap applied after EVERY record call rather than once " + "per sweep -- which on an empire with more than twenty designs in service evicts and " + "re-creates records inside the one sweep and resets their first-seen turn. A fourth rule " + "is a guard on the DESIGN'S OWNER, not on the observer: a design owned by an NPC that is " + "not a rebel AI is never observed by anyone, and every observation record in every " + "corpus save agrees -- the NPC factions' designs appear nowhere. TWO THINGS ARE NOT " + "MODELLED AND BOTH ARE NAMED. (1) The GATE is a two-bit-per-player word on the ship that " + "is NOT serialised; this phase stands it in with ownership, which is the smallest rule " + "correct on every state the corpus holds, because on both reference pairs no player " + "observes another player's ship. It is a HYPOTHESIS and its falsifier is one save in " + "which a foreign observation record moves. (2) The phase's TECH and WEAPON arms are not " + "implemented: the original feeds the design through three set builders into the observed-" + "tech and observed-weapon lists, and those builders are not decoded. The wire's own " + "per-section option-tech list covers only 13 of the 18 tech names the reference rich " + "turn moves, so it is one of the three sources and not all of them. Measured: on the " + "canonical pair 1 leaf closed / 0 regressed; on the rich turn the design half closes 34 " + "of its 55 and the 21 that remain are the shadow of ship construction -- two designs " + "receive their first ships during that turn, and no phase in this engine builds a ship, " + "so those two observations cannot happen and the two evictions they would have caused do " + "not happen either. Fed the true post-turn ship list instead, the same code leaves 1 leaf " + "of 55, so the mechanism is not the residual"}, {Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""}, {Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked, "fills every player's turn record and archives it by turn; must stay last. Thirteen of " diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 1bae886..666de26 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -11,6 +11,7 @@ #include "app/construction_phase.h" #include "app/event_phase.h" #include "app/growth_phase.h" +#include "app/observed_phase.h" #include "app/script_phase.h" #include "app/trade_raid.h" #include "app/treaty.h" @@ -1360,6 +1361,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.leafWrites = s.leafWrites; rec.committed = s.leafWrites > 0; rec.notes = s.notes; + } else if (tp[i].index == 34) { + const ObservedDesignsResult o = RunRecordObservedDesigns(game); + rec.invocations = o.recordCalls; + rec.leafWrites = o.leafWrites; + rec.committed = o.leafWrites > 0; + rec.notes = o.notes; } else if (tp[i].index == 31) { RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns); } else if (tp[i].index == 36) { diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt index 5925cda..b145859 100644 --- a/src/game/sim/CMakeLists.txt +++ b/src/game/sim/CMakeLists.txt @@ -9,6 +9,7 @@ add_library(sots_game_sim STATIC colony.cpp movement.cpp visibility.cpp + observed.cpp scriptobjects.cpp techgraph.cpp player_turn.cpp) @@ -21,7 +22,7 @@ endif() option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) if(SOTS_GAME_SIM_TESTS) enable_testing() - set(_sim_tests economy research colony movement techgraph visibility construction player_turn scriptobjects) + set(_sim_tests economy research colony movement techgraph visibility observed construction player_turn scriptobjects) foreach(_t IN LISTS _sim_tests) add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) diff --git a/src/game/sim/observed.cpp b/src/game/sim/observed.cpp new file mode 100644 index 0000000..33bed7b --- /dev/null +++ b/src/game/sim/observed.cpp @@ -0,0 +1,55 @@ +#include "game/sim/observed.h" + +#include + +namespace sots::sim { + +RecordObservedDesignResult RecordObservedDesign(std::vector& list, + std::int32_t designId, std::int32_t ownerId, + std::int32_t turn) { + RecordObservedDesignResult r; + + // The search key is the design id alone. The owner is written into a new record and is + // read by the cap below, but it is not part of the lookup -- matching the original, + // which compares one word. + const auto it = std::find_if(list.begin(), list.end(), + [designId](const ObservedDesign& o) { return o.designId == designId; }); + + if (it == list.end()) { + ObservedDesign rec; + rec.turnFirst = turn; + rec.designId = designId; + rec.ownerId = ownerId; + list.push_back(rec); + r.created = true; + } else { + // Erase and re-append, carrying the record's own fields. `turnFirst` therefore + // survives a re-observation, and the list order becomes last-observation order. + const ObservedDesign carried = *it; + list.erase(it); + list.push_back(carried); + r.moved = true; + } + list.back().turnLast = turn; + + // The cap, applied here and not once per sweep: walk from the most recent end, count the + // records belonging to this design's owner, and drop everything past the twentieth. Other + // owners' records are stepped over, not counted -- the bucket is per owner. + int kept = 0; + for (std::size_t i = list.size(); i-- > 0;) { + if (list[i].ownerId != ownerId) continue; + if (kept >= kObservedDesignsPerOwner) { + r.evictedIds.push_back(list[i].designId); + list.erase(list.begin() + static_cast(i)); + ++r.evicted; + } else { + ++kept; + } + } + // The walk is back-to-front, so the ids came out newest-evicted first; report them the + // way the list reads. + std::reverse(r.evictedIds.begin(), r.evictedIds.end()); + return r; +} + +} // namespace sots::sim diff --git a/src/game/sim/observed.h b/src/game/sim/observed.h new file mode 100644 index 0000000..8b4e071 --- /dev/null +++ b/src/game/sim/observed.h @@ -0,0 +1,83 @@ +// The per-player record of which SHIP DESIGNS an empire has observed. +// +// A player carries three parallel observation lists -- designs, weapons and technologies. +// This module models the first. Its shape is a plain vector of +// `{turnFirst, turnLast, designId, ownerId}`, and everything interesting about it is in how +// the vector is MAINTAINED, because three of those rules are invisible from the save alone +// and all three change the answer: +// +// * the lookup key is the DESIGN ID ALONE, not the (design, owner) pair; +// * a re-observation ERASES the existing record and PUSHES A COPY ON THE BACK -- it does +// not update in place. So the list is ordered by LAST observation, and `turnFirst` +// survives the move. This is why a turn's output is a permutation of its input rather +// than an append; +// * the list is CAPPED AT 20 RECORDS PER DESIGN OWNER, counted from the most recent end, +// and the cap is applied after EVERY SINGLE record call rather than once per sweep. On +// an empire with more than twenty designs in service that thrashes inside one sweep: +// records are evicted and re-created, and a re-created record's `turnFirst` is reset to +// the current turn. A model that caps once at the end produces a different list. +// +// The gate -- which (player, ship) pairs are offered to `Record` at all -- is NOT here. It +// lives on the ship, in a two-bit-per-player field that is not serialised, and the caller +// stands in for it. See `app/observed_phase.h`. +// +// Pure: no save shapes, no I/O. +#pragma once + +#include +#include + +namespace sots::sim { + +// The cap the original enforces, per design owner, counted from the back of the list. +inline constexpr int kObservedDesignsPerOwner = 20; + +// The runtime companion of the per-player masks is capped at fifteen players, and the +// design-observation sweep skips any player whose slot is at or above it. Slots are the +// player's index, not its handle id. +inline constexpr int kObserverSlotLimit = 15; + +struct ObservedDesign { + std::int32_t turnFirst = 0; // `otnF` -- the turn this record was created + std::int32_t turnLast = 0; // `otnL` -- the turn it was last refreshed + std::int32_t designId = 0; // `odid` + std::int32_t ownerId = 0; // `opid` -- the handle id of the design's owning player + + bool operator==(const ObservedDesign& o) const { + return turnFirst == o.turnFirst && turnLast == o.turnLast && designId == o.designId && + ownerId == o.ownerId; + } + bool operator!=(const ObservedDesign& o) const { return !(*this == o); } +}; + +// What one `Record` call did, so a caller can report mechanism rather than a leaf count. +struct RecordObservedDesignResult { + bool created = false; // the design was not in the list and a record was appended + bool moved = false; // it was in the list and was erased and re-appended + int evicted = 0; // records the cap dropped on this call + // The design ids the cap dropped, oldest first. A caller that models the original's + // "forgotten design" notification needs them; nothing on the wire does. + std::vector evictedIds; +}; + +// Record one observation. `turn` is the turn being recorded (`otnL`, and `otnF` on a +// create). `ownerId` is the design's owner, which decides both the record's `opid` and which +// cap bucket the trim counts. +RecordObservedDesignResult RecordObservedDesign(std::vector& list, + std::int32_t designId, std::int32_t ownerId, + std::int32_t turn); + +// Is this player's slot one the sweep visits at all? +constexpr bool ObserverSlotVisited(int playerSlot) { + return playerSlot >= 0 && playerSlot < kObserverSlotLimit; +} + +// The guard the original applies to the DESIGN'S OWNER before recording anything: a design +// owned by an NPC that is not a rebel AI is never observed by anyone. Measured against the +// whole save corpus: every observation record in every save names a design owned by one of +// the two non-NPC empires, and the four NPC factions' twenty-six designs appear nowhere. +constexpr bool DesignOwnerIsObservable(bool ownerIsNpc, bool ownerIsRebelAi) { + return !ownerIsNpc || ownerIsRebelAi; +} + +} // namespace sots::sim diff --git a/tests/game_sim/test_observed.cpp b/tests/game_sim/test_observed.cpp new file mode 100644 index 0000000..b653e09 --- /dev/null +++ b/tests/game_sim/test_observed.cpp @@ -0,0 +1,158 @@ +#include "game/sim/observed.h" + +#include + +#include "check.h" + +using namespace sots::sim; + +static ObservedDesign rec(std::int32_t first, std::int32_t last, std::int32_t design, + std::int32_t owner) { + ObservedDesign o; + o.turnFirst = first; + o.turnLast = last; + o.designId = design; + o.ownerId = owner; + return o; +} + +static std::vector ids(const std::vector& v) { + std::vector out; + for (const auto& o : v) out.push_back(o.designId); + return out; +} + +static void test_create() { + std::vector l; + const auto r = RecordObservedDesign(l, /*design*/ 18, /*owner*/ 32, /*turn*/ 3); + CHECK(r.created); + CHECK(!r.moved); + CHECK_EQ(r.evicted, 0); + CHECK_EQ(l.size(), std::size_t{1}); + CHECK_EQ(l[0].turnFirst, 3); + CHECK_EQ(l[0].turnLast, 3); + CHECK_EQ(l[0].designId, 18); + CHECK_EQ(l[0].ownerId, 32); +} + +// The whole point of the record: a second sighting stamps `turnLast` and leaves `turnFirst` +// where it was. If this ever regresses, every leaf in the list moves. +static void test_refresh_keeps_first_seen() { + std::vector l{rec(2, 2, 18, 32)}; + const auto r = RecordObservedDesign(l, 18, 32, 3); + CHECK(!r.created); + CHECK(r.moved); + CHECK_EQ(l.size(), std::size_t{1}); + CHECK_EQ(l[0].turnFirst, 2); + CHECK_EQ(l[0].turnLast, 3); +} + +// Re-observation is erase + push_back, NOT an in-place update, so the list ends up in +// last-observation order. A model that updated in place would leave A B C here. +static void test_reobservation_moves_to_the_back() { + std::vector l{rec(1, 1, 10, 32), rec(1, 1, 20, 32), rec(1, 1, 30, 32)}; + RecordObservedDesign(l, 10, 32, 5); + CHECK(ids(l) == (std::vector{20, 30, 10})); + RecordObservedDesign(l, 20, 32, 5); + CHECK(ids(l) == (std::vector{30, 10, 20})); + // and the moved records kept their first-seen turn + CHECK_EQ(l[1].turnFirst, 1); + CHECK_EQ(l[2].turnFirst, 1); +} + +// The lookup key is the design id ALONE. Two owners cannot normally share one, but the +// original compares a single word and this pins that reading. +static void test_key_is_the_design_id_alone() { + std::vector l{rec(1, 1, 10, 32)}; + const auto r = RecordObservedDesign(l, 10, /*a different owner*/ 16, 5); + CHECK(r.moved); + CHECK_EQ(l.size(), std::size_t{1}); + CHECK_EQ(l[0].ownerId, 32); // the carried record keeps its own owner +} + +// Twenty per owner, counted from the most recent end, so the eviction comes off the FRONT. +static void test_cap_is_twenty_per_owner() { + std::vector l; + for (int i = 0; i < kObservedDesignsPerOwner; ++i) + RecordObservedDesign(l, 100 + i, 32, 1); + CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner}); + + const auto r = RecordObservedDesign(l, 999, 32, 2); + CHECK(r.created); + CHECK_EQ(r.evicted, 1); + CHECK_EQ(r.evictedIds.size(), std::size_t{1}); + CHECK_EQ(r.evictedIds[0], 100); // the oldest went + CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner}); + CHECK_EQ(l.front().designId, 101); + CHECK_EQ(l.back().designId, 999); +} + +// The bucket is per design OWNER: a full list for owner 32 does not evict owner 16's record, +// and owner 16's record is stepped over rather than counted. +static void test_cap_buckets_by_owner() { + std::vector l{rec(1, 1, 7, 16)}; + for (int i = 0; i < kObservedDesignsPerOwner; ++i) + RecordObservedDesign(l, 100 + i, 32, 1); + CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner + 1}); + CHECK_EQ(l.front().designId, 7); + + RecordObservedDesign(l, 999, 32, 2); + CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner + 1}); + CHECK_EQ(l.front().designId, 7); // still there + CHECK_EQ(l[1].designId, 101); // 100 was the one that went + + // and owner 16 can still reach twenty of its own + for (int i = 0; i < kObservedDesignsPerOwner - 1; ++i) + RecordObservedDesign(l, 200 + i, 16, 3); + int sixteens = 0; + for (const auto& o : l) + if (o.ownerId == 16) ++sixteens; + CHECK_EQ(sixteens, kObservedDesignsPerOwner); +} + +// The cap runs after EVERY call, not once at the end. Twenty-one designs seen in turn, each +// once, leaves the LAST twenty -- and if the evicted one is then seen again it comes back as +// a NEW record with this turn as its first-seen. That reset is what the reference save shows +// on an empire with twenty-eight designs in service. +static void test_thrash_resets_first_seen() { + std::vector l; + for (int i = 0; i <= kObservedDesignsPerOwner; ++i) + RecordObservedDesign(l, 100 + i, 32, /*turn*/ 5); + CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner}); + CHECK_EQ(l.front().designId, 101); + + const auto again = RecordObservedDesign(l, 100, 32, 6); + CHECK(again.created); // it was evicted, so this is a CREATE + CHECK_EQ(l.back().turnFirst, 6); // ... and the first-seen turn is reset + CHECK_EQ(again.evicted, 1); + CHECK_EQ(again.evictedIds[0], 101); +} + +static void test_slot_limit() { + CHECK(ObserverSlotVisited(0)); + CHECK(ObserverSlotVisited(14)); + CHECK(!ObserverSlotVisited(15)); + CHECK(!ObserverSlotVisited(-1)); +} + +// The guard is on the DESIGN'S OWNER: an NPC's designs are invisible to everyone, unless the +// NPC is a rebel AI. +static void test_owner_guard() { + CHECK(DesignOwnerIsObservable(/*npc*/ false, /*rebel*/ false)); + CHECK(DesignOwnerIsObservable(false, true)); + CHECK(!DesignOwnerIsObservable(true, false)); + CHECK(DesignOwnerIsObservable(true, true)); +} + +int main() { + test_create(); + test_refresh_keeps_first_seen(); + test_reobservation_moves_to_the_back(); + test_key_is_the_design_id_alone(); + test_cap_is_twenty_per_owner(); + test_cap_buckets_by_owner(); + test_thrash_resets_first_seen(); + test_slot_limit(); + test_owner_guard(); + return simtest::finish("observed"); +}