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"); +}