#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 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 before; Observation o; o.playerSlot = 1; o.playerId = 32; o.turnSeen = 22; o.encounterId = kNoEncounter; before.push_back(o); const std::vector 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 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 before; Observation o; o.playerSlot = 4; o.playerId = 528; o.turnSeen = 2; o.encounterId = 5; before.push_back(o); const std::vector 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 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"); }