sots-engine/tests/game_sim/test_visibility.cpp
alex a1910becb0 lane E3: the per-system visibility record, the explored sweep and the system observed-stamp
Three phases, one input. A star system carries four per-player masks and three of
them agree on nearly every system of every save the corpus holds, so a model built
on the wrong one looks right until it does not. The gate is the DERIVED
active-presence mask -- fleet-here OR gate-here OR owner, recomputed on every
arrival and departure -- not the sticky one and not the explored one.

  S29 SystemObservedStamp   the system's own last-observed turn (whole function)
  T17 RebuildPlayerViewTree the per-(system, player) observation record: who saw
                            the system, on what turn, and what encounter was there
  T21 UpdateSurveyAndStats  the explored sweep: seen this turn implies surveyed

game/sim/visibility is pure and knows nothing about save shapes; app/visibility_phase
wires it to them. The mask is READ FROM THE SAVE and never rebuilt: neither reference
pair moves a mask leaf, so the loaded value is the value these phases would see, and
rebuilding it from an unmodelled movement pass would be a change with no evidence.

Measured, closed and regressed reported separately and never netted:

  turn1-state -> turn2-state    209 -> 158   closed 51, regressed 0
  turn2-state -> turn3-state    108 ->  87   closed 21, regressed 0

of which this lane closed 46 and 16 (the rest were already closed at main). The 46
are the brief's 32-leaf target in full -- 8 record counts, 8 player ids, 8 turn
stamps, 8 encounter ids -- plus 8 system stamps and 6 explored masks.

Three further pairs the model was never fitted to, all zero regressions:

  human-turn2 -> human-turn3    353 -> 311   closed 42   (a different game, 21 systems)
  zuul15 -> zuul16              276 -> 264   closed 12
  zuul16 -> zuul17              341 -> 329   closed 12

The corpus's one discriminating row is a host test rather than a comment: a system
whose last visiting fleet has gone carries the sticky and explored bits set, the
active bit clear, and a stamp frozen a turn behind. The test asserts the freeze AND
asserts what the wrong gate would have produced, so a future edit that swaps the
mask fails loudly instead of quietly agreeing with five saves.

Labelled hypothesis, with the workload named in the header: the encounter id is
recovered from the encounter fleet at the system, because the field the original
reads is set once at map generation and is not on the wire. It agrees on all six
encounter fleets in the corpus and no save can separate it -- none kills an
encounter while leaving its system visible.

Not written, deliberately: the colony-ownership stamp that moves beside these.
Its gate is demonstrably NOT the active mask (one system in the corpus has a zero
mask and moves it anyway), the formula is not held, so it is reported, not written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:38:12 -04:00

203 lines
8.1 KiB
C++

#include "game/sim/visibility.h"
#include "check.h"
using namespace sots::sim;
static SystemMasks masks(std::int32_t seen, std::int32_t explored, std::int32_t active,
std::int32_t presence, std::int32_t gate) {
SystemMasks m;
m.seen = seen;
m.explored = explored;
m.active = active;
m.presence = presence;
m.gate = gate;
return m;
}
static void test_bits() {
CHECK(SlotRepresentable(0));
CHECK(SlotRepresentable(31));
CHECK(!SlotRepresentable(-1));
CHECK(!SlotRepresentable(32));
CHECK_EQ(SlotBit(0), std::int32_t{1});
CHECK_EQ(SlotBit(4), std::int32_t{16});
CHECK_EQ(SlotBit(7), std::int32_t{128});
CHECK_EQ(SlotBit(32), std::int32_t{0});
}
// The gate is the ACTIVE mask. Three masks agree on nearly every system of every save the
// project holds, so this pins the one thing the corpus almost cannot show.
static void test_gate_is_the_active_mask() {
// seen and explored set, active clear: this is the shape a system takes after its last
// visiting fleet leaves. It must NOT be treated as observed.
const SystemMasks m = masks(/*seen*/ 2, /*explored*/ 2, /*active*/ 0, 0, 0);
CHECK(!IsObservedBy(m, 1));
CHECK(IsExploredBy(m, 1));
const SystemMasks live = masks(2, 2, 2, 2, 0);
CHECK(IsObservedBy(live, 1));
}
static void test_active_recompute() {
// presence | gate | owner, and the owner term is a bit, not a flag.
CHECK_EQ(RecomputeActive(2, 0, 0), std::int32_t{2});
CHECK_EQ(RecomputeActive(0, 4, 0), std::int32_t{4});
CHECK_EQ(RecomputeActive(0, 0, 16), std::int32_t{16});
CHECK_EQ(RecomputeActive(2, 4, 16), std::int32_t{22});
CHECK_EQ(RecomputeActive(0, 0, 0), std::int32_t{0});
}
static void test_explored_sweep() {
CHECK_EQ(ApplyExploredSweep(0, 16), std::int32_t{16});
CHECK_EQ(ApplyExploredSweep(1, 1), std::int32_t{1}); // idempotent
CHECK_EQ(ApplyExploredSweep(2, 0), std::int32_t{2}); // never cleared
CHECK_EQ(ApplyExploredSweep(1, 16), std::int32_t{17}); // accumulates
const std::vector<int> newly = NewlyExploredSlots(1, 0x11);
CHECK_EQ(newly.size(), std::size_t{1});
CHECK_EQ(newly[0], 4);
CHECK_EQ(NewlyExploredSlots(0x11, 0x11).size(), std::size_t{0});
}
static void test_system_stamp() {
// Watched: the stamp becomes this turn.
SystemStampResult r = UpdateSystemStamp(1, /*active*/ 16, /*turn*/ 2);
CHECK_EQ(r.stamp, std::int32_t{2});
CHECK(r.changed);
// Already current: no leaf moves.
r = UpdateSystemStamp(2, 16, 2);
CHECK_EQ(r.stamp, std::int32_t{2});
CHECK(!r.changed);
// Unwatched: the old stamp stands. This is the Bismol case at the system level.
r = UpdateSystemStamp(22, /*active*/ 0, /*turn*/ 23);
CHECK_EQ(r.stamp, std::int32_t{22});
CHECK(!r.changed);
}
// The corpus's one discriminating row, replayed as a rule.
//
// `zuul-turn23-fleet23.sav`, system "Bismol": the visiting fleet has gone, so the sticky and
// explored masks still carry the player's bit while the active mask does not, and the saved
// record's stamp is 22 while the save is turn 23. A model gated on the sticky mask -- or on
// explored -- refreshes it to 23 and is wrong. A model gated on active leaves it alone.
static void test_bismol_freeze() {
std::vector<Observation> before;
Observation o;
o.playerSlot = 1;
o.playerId = 32;
o.turnSeen = 22;
o.encounterId = kNoEncounter;
before.push_back(o);
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 0, 0, 0};
const ObservationUpdate up =
UpdateObservations(before, masks(/*seen*/ 2, /*explored*/ 2, /*active*/ 0, 0, 0), slots,
kNoEncounter, /*turn*/ 23);
CHECK_EQ(up.records.size(), std::size_t{1});
CHECK_EQ(up.records[0].turnSeen, std::int32_t{22}); // FROZEN
CHECK_EQ(up.refreshed, 0);
CHECK_EQ(up.created, 0);
CHECK_EQ(up.untouched, 1);
// The same row under the wrong gate, stated so the failure mode is visible: had the model
// used the sticky mask the record would have moved to 23.
const ObservationUpdate wrong =
UpdateObservations(before, masks(2, 2, /*active*/ 2, 0, 0), slots, kNoEncounter, 23);
CHECK_EQ(wrong.records[0].turnSeen, std::int32_t{23});
CHECK_EQ(wrong.refreshed, 1);
}
static void test_creation_and_refresh() {
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 528, 0, 0, 576};
// Turn 1 -> 2 at Hyperion: no record, active mask carries slot 4, an encounter is there.
ObservationUpdate up = UpdateObservations({}, masks(16, 0, 16, 16, 0), slots,
/*encounter*/ 5, /*turn*/ 2);
CHECK_EQ(up.records.size(), std::size_t{1});
CHECK_EQ(up.created, 1);
CHECK_EQ(up.records[0].playerSlot, 4);
CHECK_EQ(up.records[0].playerId, std::int32_t{528}); // the HANDLE id, not the slot
CHECK_EQ(up.records[0].turnSeen, std::int32_t{2});
CHECK_EQ(up.records[0].encounterId, std::int32_t{5});
// Turn 2 -> 3 at the same system: the stamp moves, the encounter id does not.
const ObservationUpdate next =
UpdateObservations(up.records, masks(16, 16, 16, 16, 0), slots, 5, 3);
CHECK_EQ(next.records.size(), std::size_t{1});
CHECK_EQ(next.created, 0);
CHECK_EQ(next.refreshed, 1);
CHECK_EQ(next.records[0].turnSeen, std::int32_t{3});
CHECK_EQ(next.records[0].encounterId, std::int32_t{5});
CHECK_EQ(next.records[0].playerId, std::int32_t{528});
// A system nobody can see gains nothing. This is Spica: a colony record exists, the
// active mask is zero, and no observation record is ever created.
const ObservationUpdate none =
UpdateObservations({}, masks(0, 0, 0, 0, 0), slots, kNoEncounter, 2);
CHECK_EQ(none.records.size(), std::size_t{0});
CHECK_EQ(none.created, 0);
}
// An existing record keeps its encounter id even if nothing derivable is at the system now.
// The field the original reads is fixed at map generation; re-deriving it every turn is a
// different rule that no save can separate, so the one that cannot lose information is used.
static void test_encounter_id_is_sticky() {
std::vector<Observation> before;
Observation o;
o.playerSlot = 4;
o.playerId = 528;
o.turnSeen = 2;
o.encounterId = 5;
before.push_back(o);
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 528};
const ObservationUpdate up = UpdateObservations(
before, masks(16, 16, 16, 16, 0), slots, /*nothing derivable now*/ kNoEncounter, 3);
CHECK_EQ(up.records[0].encounterId, std::int32_t{5});
CHECK_EQ(up.records[0].turnSeen, std::int32_t{3});
}
// Two observers of one system. Never exercised by any save in the corpus -- every system in
// it has a single-bit active mask -- so this pins the ordering the container imposes rather
// than a behaviour anyone has seen.
static void test_two_observers_order_by_slot() {
const std::vector<std::int32_t> slots = {16, 32, 0, 512, 528, 0, 0, 576};
// slots 7 and 1 both observe; the list must come out 1 then 7.
const ObservationUpdate up =
UpdateObservations({}, masks(0x82, 0, 0x82, 0x82, 0), slots, kNoEncounter, 4);
CHECK_EQ(up.records.size(), std::size_t{2});
CHECK_EQ(up.records[0].playerSlot, 1);
CHECK_EQ(up.records[0].playerId, std::int32_t{32});
CHECK_EQ(up.records[1].playerSlot, 7);
CHECK_EQ(up.records[1].playerId, std::int32_t{576});
}
static void test_sharing_rule() {
Observation a, b;
a.turnSeen = 10;
b.turnSeen = 8;
CHECK(ShouldShareObservation(&a, &b)); // newer donor wins
CHECK(!ShouldShareObservation(&b, &a)); // older donor does not
CHECK(ShouldShareObservation(&a, nullptr));
CHECK(!ShouldShareObservation(nullptr, &a));
b.turnSeen = 10;
CHECK(!ShouldShareObservation(&a, &b)); // equal is not newer
}
int main() {
test_bits();
test_gate_is_the_active_mask();
test_active_recompute();
test_explored_sweep();
test_system_stamp();
test_bismol_freeze();
test_creation_and_refresh();
test_encounter_id_is_sticky();
test_two_observers_order_by_slot();
test_sharing_rule();
return simtest::finish("visibility");
}