Merge branch 'wip/dt'

This commit is contained in:
alex 2026-09-09 10:19:47 -04:00
commit 7741d42fc5
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");
}