sots-engine/tests/game_sim/test_observed.cpp
alex 586171cb72 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.
2026-09-09 10:15:12 -04:00

158 lines
5.9 KiB
C++

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