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.
This commit is contained in:
alex 2026-09-09 10:15:12 -04:00
parent e7e2bd6f62
commit 586171cb72
9 changed files with 554 additions and 2 deletions

View file

@ -12,6 +12,7 @@ add_library(sots_app STATIC
construction_phase.cpp construction_phase.cpp
event_phase.cpp event_phase.cpp
growth_phase.cpp growth_phase.cpp
observed_phase.cpp
script_phase.cpp script_phase.cpp
visibility_phase.cpp visibility_phase.cpp
command_replay.cpp command_replay.cpp

172
src/app/observed_phase.cpp Normal file
View file

@ -0,0 +1,172 @@
#include "app/observed_phase.h"
#include <cstdarg>
#include <cstdio>
#include <unordered_map>
#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<std::int32_t, DesignOwner> DesignOwners(const SaveGame& game) {
std::unordered_map<std::int32_t, DesignOwner> 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<sim::ObservedDesign> FromWire(const std::vector<Odes>& in) {
std::vector<sim::ObservedDesign> 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<Odes>& before, const std::vector<sim::ObservedDesign>& 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<int>((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<std::int32_t, DesignOwner> 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<sim::ObservedDesign> 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<Odes> 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

47
src/app/observed_phase.h Normal file
View file

@ -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 <string>
#include <vector>
#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<std::string> 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

View file

@ -333,7 +333,35 @@ constexpr PhaseDesc kTail[] = {
"is a fact only a running process could supply"}, "is a fact only a running process could supply"},
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", 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, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked, {Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked,
"fills every player's turn record and archives it by turn; must stay last. Thirteen of " "fills every player's turn record and archives it by turn; must stay last. Thirteen of "

View file

@ -11,6 +11,7 @@
#include "app/construction_phase.h" #include "app/construction_phase.h"
#include "app/event_phase.h" #include "app/event_phase.h"
#include "app/growth_phase.h" #include "app/growth_phase.h"
#include "app/observed_phase.h"
#include "app/script_phase.h" #include "app/script_phase.h"
#include "app/trade_raid.h" #include "app/trade_raid.h"
#include "app/treaty.h" #include "app/treaty.h"
@ -1360,6 +1361,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.leafWrites = s.leafWrites; rec.leafWrites = s.leafWrites;
rec.committed = s.leafWrites > 0; rec.committed = s.leafWrites > 0;
rec.notes = s.notes; 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) { } else if (tp[i].index == 31) {
RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns); RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns);
} else if (tp[i].index == 36) { } else if (tp[i].index == 36) {

View file

@ -9,6 +9,7 @@ add_library(sots_game_sim STATIC
colony.cpp colony.cpp
movement.cpp movement.cpp
visibility.cpp visibility.cpp
observed.cpp
scriptobjects.cpp scriptobjects.cpp
techgraph.cpp techgraph.cpp
player_turn.cpp) player_turn.cpp)
@ -21,7 +22,7 @@ endif()
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
if(SOTS_GAME_SIM_TESTS) if(SOTS_GAME_SIM_TESTS)
enable_testing() 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) foreach(_t IN LISTS _sim_tests)
add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) 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) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)

55
src/game/sim/observed.cpp Normal file
View file

@ -0,0 +1,55 @@
#include "game/sim/observed.h"
#include <algorithm>
namespace sots::sim {
RecordObservedDesignResult RecordObservedDesign(std::vector<ObservedDesign>& 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<std::ptrdiff_t>(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

83
src/game/sim/observed.h Normal file
View file

@ -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 <cstdint>
#include <vector>
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<std::int32_t> 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<ObservedDesign>& 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

View file

@ -0,0 +1,158 @@
#include "game/sim/observed.h"
#include <vector>
#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<std::int32_t> ids(const std::vector<ObservedDesign>& v) {
std::vector<std::int32_t> out;
for (const auto& o : v) out.push_back(o.designId);
return out;
}
static void test_create() {
std::vector<ObservedDesign> 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<ObservedDesign> 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<ObservedDesign> 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<std::int32_t>{20, 30, 10}));
RecordObservedDesign(l, 20, 32, 5);
CHECK(ids(l) == (std::vector<std::int32_t>{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<ObservedDesign> 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<ObservedDesign> 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<ObservedDesign> 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<ObservedDesign> 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");
}