diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 3b220a7..02824cf 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(sots_app STATIC phase_catalog.cpp trade_raid.cpp turn_record.cpp + visibility_phase.cpp turn.cpp report.cpp) target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 09aa7be..98daa06 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -109,7 +109,10 @@ constexpr PhaseDesc kStrategic[] = { {Driver::Strategic, 28, "S28", "GrantMetSpeciesTechs", PhaseStatus::Stub, "the 'you have met this race, its racial tech appears in your tree' rule -- small, pure, " "draw-free, and the cheapest unimplemented phase in this table"}, - {Driver::Strategic, 29, "S29", "SystemTailFixup", PhaseStatus::Stub, ""}, + {Driver::Strategic, 29, "S29", "SystemObservedStamp", PhaseStatus::Implemented, + "the system's own last-observed turn, written wherever some player can currently see " + "the system and left alone everywhere else. Whole function modelled; the gate is the " + "active-presence mask, which falls when the last fleet leaves"}, {Driver::Strategic, 30, "S30", "BuildTeamPartition", PhaseStatus::Stub, ""}, {Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Partial, "trade-raid generation runs first here and IS modelled: two chances per player, one word " @@ -192,11 +195,19 @@ constexpr PhaseDesc kTail[] = { "the decision function is modelled in game::sim; the per-player state it reads is not " "assembled here"}, {Driver::Tail, 16, "T16", "ResolveArrivedColonizers", PhaseStatus::Stub, ""}, - {Driver::Tail, 17, "T17", "RebuildPlayerViewTree", PhaseStatus::Stub, ""}, + {Driver::Tail, 17, "T17", "RebuildPlayerViewTree", PhaseStatus::Partial, + "the per-(system, player) observation record IS modelled and committed -- who saw " + "the system, on what turn, and what encounter was there. The colony-numbers view " + "the same phase rebuilds beside it is NOT: its list is empty on both reference " + "pairs, so nothing here has evidence to build it against"}, {Driver::Tail, 18, "T18", "PostFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 19, "T19", "DrainInfraTerraformQueue", PhaseStatus::Stub, ""}, {Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Stub, ""}, - {Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Stub, ""}, + {Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Partial, + "the explored sweep IS modelled and committed: every player who can currently see a " + "system has now surveyed it. The event this owes per newly-surveyed pair is not " + "posted, and the derived per-system defence figure the same phase computes is not " + "modelled"}, {Driver::Tail, 22, "T22", "TradeSliderFinalisationSecondPass", PhaseStatus::Stub, ""}, {Driver::Tail, 23, "T23", "TradeManagerEndOfTurnHooks", PhaseStatus::Stub, "eight vtable calls, wholly unidentified"}, diff --git a/src/app/turn.cpp b/src/app/turn.cpp index b29e8a8..7c11de3 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -10,6 +10,7 @@ #include "app/alliance.h" #include "app/trade_raid.h" #include "app/turn_record.h" +#include "app/visibility_phase.h" #include "game/sim/colony.h" #include "game/sim/economy.h" #include "game/sim/numeric.h" @@ -594,6 +595,14 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { playerDriverRan = true; break; } + case 29: { // S29 SystemObservedStamp + const VisibilityPhaseResult v = RunSystemObservedStamp(game); + rec.invocations = v.systemsVisited; + rec.leafWrites = v.leafWrites; + rec.committed = v.leafWrites > 0; + rec.notes = v.notes; + break; + } case 31: { // S31 EncounterDetectionAndStatusRestore // Trade-raid generation runs FIRST inside this phase, before detection // proper, and it is the turn's dominant RNG consumer: two chances per entry @@ -693,6 +702,18 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.leafWrites = 1; rec.committed = true; rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); + } else if (tp[i].index == 17) { + const VisibilityPhaseResult v = RunObservationRecords(game); + rec.invocations = v.systemsVisited; + rec.leafWrites = v.leafWrites; + rec.committed = v.leafWrites > 0; + rec.notes = v.notes; + } else if (tp[i].index == 21) { + const VisibilityPhaseResult v = RunExploredSweep(game); + rec.invocations = v.systemsVisited; + rec.leafWrites = v.leafWrites; + rec.committed = v.leafWrites > 0; + rec.notes = v.notes; } else if (tp[i].index == 36) { RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks); } diff --git a/src/app/visibility_phase.cpp b/src/app/visibility_phase.cpp new file mode 100644 index 0000000..98a0e77 --- /dev/null +++ b/src/app/visibility_phase.cpp @@ -0,0 +1,175 @@ +#include "app/visibility_phase.h" + +#include +#include +#include +#include + +#include "game/sim/visibility.h" + +namespace sots::app { +namespace { + +using mars::stream::shapes::SaveGame; +using mars::stream::shapes::Sys; + +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); +} + +sim::SystemMasks MasksOf(const Sys& s) { + sim::SystemMasks m; + m.seen = s.vFlags; + m.explored = s.eFlags; + m.active = s.aFlags; + m.presence = s.fFlags; + m.gate = s.gFlags; + return m; +} + +// slot -> player handle id, sized to the highest slot the save uses. A slot no player +// occupies maps to 0 and is never observed, because no mask bit can be set for it. +std::vector SlotToPlayerId(const SaveGame& game) { + int maxSlot = -1; + for (const auto& e : game.sim.players) maxSlot = std::max(maxSlot, e.player.plyrIdx); + if (maxSlot < 0) return {}; + std::vector out(static_cast(maxSlot) + 1, 0); + for (const auto& e : game.sim.players) { + if (!sim::SlotRepresentable(e.player.plyrIdx)) continue; + out[static_cast(e.player.plyrIdx)] = e.playerID; + } + return out; +} + +} // namespace + +std::int32_t EncounterAtSystem(const SaveGame& game, std::int32_t systemId) { + for (const auto& f : game.sim.fleets) { + if (f.flt.locID != systemId) continue; + if (f.flt.ftEnc == 0) continue; // "no encounter"; the system defence fleet uses it + return f.flt.ftEnc; + } + return sim::kNoEncounter; +} + +VisibilityPhaseResult RunSystemObservedStamp(SaveGame& game) { + VisibilityPhaseResult r; + int watched = 0; + for (auto& e : game.sim.systems) { + ++r.systemsVisited; + if (e.sys.aFlags != 0) ++watched; + const sim::SystemStampResult s = + sim::UpdateSystemStamp(e.sys.ltis, e.sys.aFlags, game.sim.frame); + if (!s.changed) continue; + e.sys.ltis = s.stamp; + ++r.systemsTouched; + ++r.leafWrites; + } + r.notes.push_back(fmt("%d of %d system(s) are watched by someone; %d stamp(s) moved to %d", + watched, r.systemsVisited, r.leafWrites, game.sim.frame)); + if (watched != r.systemsVisited) + r.notes.push_back("an unwatched system keeps the turn it was last watched -- the gate " + "is the ACTIVE mask, which falls when the last fleet leaves, not the " + "sticky one"); + return r; +} + +VisibilityPhaseResult RunExploredSweep(SaveGame& game) { + VisibilityPhaseResult r; + int newlyExplored = 0; + for (auto& e : game.sim.systems) { + ++r.systemsVisited; + const std::int32_t next = sim::ApplyExploredSweep(e.sys.eFlags, e.sys.aFlags); + if (next == e.sys.eFlags) continue; + newlyExplored += static_cast( + sim::NewlyExploredSlots(e.sys.eFlags, e.sys.aFlags).size()); + e.sys.eFlags = next; + ++r.systemsTouched; + ++r.leafWrites; + } + r.notes.push_back(fmt("%d system(s) newly surveyed, across %d (system, player) pair(s)", + r.systemsTouched, newlyExplored)); + if (newlyExplored) + r.notes.push_back(fmt("the original posts one event per pair, so this phase owes %d " + "event(s) it does not post", + newlyExplored)); + return r; +} + +VisibilityPhaseResult RunObservationRecords(SaveGame& game) { + VisibilityPhaseResult r; + const std::vector slotToId = SlotToPlayerId(game); + int created = 0, refreshed = 0, derivedEncounters = 0; + + for (auto& e : game.sim.systems) { + ++r.systemsVisited; + + std::vector before; + before.reserve(e.sys.nve.size()); + for (const auto& n : e.sys.nve) { + sim::Observation o; + // The wire carries the handle id; the map key is the slot. Recover the slot so + // the ordering the container imposes is reproducible. + o.playerId = n.ePid; + o.playerSlot = -1; + for (std::size_t slot = 0; slot < slotToId.size(); ++slot) + if (slotToId[slot] == n.ePid) o.playerSlot = static_cast(slot); + o.turnSeen = n.ets; + o.encounterId = n.eid; + before.push_back(o); + } + + const std::int32_t enc = EncounterAtSystem(game, e.sysID); + if (enc != sim::kNoEncounter) ++derivedEncounters; + + const sim::ObservationUpdate up = sim::UpdateObservations( + before, MasksOf(e.sys), slotToId, enc, game.sim.frame); + created += up.created; + refreshed += up.refreshed; + + // Count the leaves that actually move. A created record adds four (the count and its + // three fields); a refreshed one moves only the fields that changed. + int moved = 0; + if (up.records.size() != e.sys.nve.size()) ++moved; // the count leaf + for (std::size_t i = 0; i < up.records.size(); ++i) { + const sim::Observation& o = up.records[i]; + if (i >= e.sys.nve.size()) { + moved += 3; + continue; + } + const auto& old = e.sys.nve[i]; + if (old.ePid != o.playerId) ++moved; + if (old.ets != o.turnSeen) ++moved; + if (old.eid != o.encounterId) ++moved; + } + if (moved == 0) continue; + + e.sys.nve.clear(); + e.sys.nve.reserve(up.records.size()); + for (const auto& o : up.records) { + mars::stream::shapes::NveEntry n; + n.ePid = o.playerId; + n.ets = o.turnSeen; + n.eid = o.encounterId; + e.sys.nve.push_back(n); + } + ++r.systemsTouched; + r.leafWrites += moved; + } + + r.notes.push_back(fmt("%d record(s) created, %d refreshed, over %d system(s); %d leaf/leaves", + created, refreshed, r.systemsTouched, r.leafWrites)); + r.notes.push_back(fmt("%d system(s) carry an encounter fleet whose id the record copies -- " + "the field the original reads is NOT on the wire, so this derivation " + "is a HYPOTHESIS and no save in the corpus can separate it from the " + "rule it stands in for", + derivedEncounters)); + return r; +} + +} // namespace sots::app diff --git a/src/app/visibility_phase.h b/src/app/visibility_phase.h new file mode 100644 index 0000000..b1f1ffe --- /dev/null +++ b/src/app/visibility_phase.h @@ -0,0 +1,55 @@ +// The visibility phases, wired to the save shapes. +// +// Three phases of the turn touch the per-system visibility cluster, in two different +// drivers, and they are listed here together because they share one input -- the system's +// active-presence mask -- and nothing else in the turn reads it. +// +// S29 the system's own last-observed stamp +// T17 the per-(system, player) observation record +// T21 the explored sweep +// +// The mask itself is READ FROM THE SAVE and never rebuilt. The original recomputes it on +// every fleet arrival and departure, which the standalone does not model; but 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 behind it. When that stops being true it will show up as a regression here +// first, which is the point of saying it out loud. +#pragma once + +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +// What one visibility phase did, in the terms the run log prints. +struct VisibilityPhaseResult { + int systemsVisited = 0; // systems the phase looked at + int systemsTouched = 0; // systems where something moved + int leafWrites = 0; // save leaves this phase changed + std::vector notes; +}; + +// S29: `ltis`, the system's own last-observed turn. Written on every system some player can +// currently see; left alone on the rest. +VisibilityPhaseResult RunSystemObservedStamp(mars::stream::shapes::SaveGame& game); + +// T21: the explored sweep -- every player who can see a system has now surveyed it. +VisibilityPhaseResult RunExploredSweep(mars::stream::shapes::SaveGame& game); + +// T17: the per-(system, player) observation record. +VisibilityPhaseResult RunObservationRecords(mars::stream::shapes::SaveGame& game); + +// The encounter parked at a system, as this model can recover it. +// +// The field the original reads is a star-system member that is NOT serialised: it is set +// once when the map is generated and never moves. The only observable that carries the same +// id is the encounter fleet sitting at the system, so that is what this reads -- and it is a +// HYPOTHESIS, not a reading. It agrees with the original on all six encounter fleets in the +// corpus and no save can separate it from the true rule, because no save kills an encounter +// while leaving its system visible. Returns -1 when there is none. +std::int32_t EncounterAtSystem(const mars::stream::shapes::SaveGame& game, + std::int32_t systemId); + +} // namespace sots::app diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt index 8cdbaf4..c234d55 100644 --- a/src/game/sim/CMakeLists.txt +++ b/src/game/sim/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(sots_game_sim STATIC research.cpp colony.cpp movement.cpp + visibility.cpp techgraph.cpp) target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) target_compile_features(sots_game_sim PUBLIC cxx_std_17) @@ -17,7 +18,7 @@ endif() option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) if(SOTS_GAME_SIM_TESTS) enable_testing() - set(_sim_tests economy research colony movement techgraph) + set(_sim_tests economy research colony movement techgraph visibility) foreach(_t IN LISTS _sim_tests) 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) diff --git a/src/game/sim/visibility.cpp b/src/game/sim/visibility.cpp new file mode 100644 index 0000000..4a78e1a --- /dev/null +++ b/src/game/sim/visibility.cpp @@ -0,0 +1,78 @@ +#include "game/sim/visibility.h" + +#include + +namespace sots::sim { + +std::vector NewlyExploredSlots(std::int32_t explored, std::int32_t active) { + std::vector out; + const std::uint32_t newly = static_cast(active) & ~static_cast(explored); + for (int slot = 0; slot < 32; ++slot) + if ((newly >> slot) & 1u) out.push_back(slot); + return out; +} + +SystemStampResult UpdateSystemStamp(std::int32_t currentStamp, std::int32_t activeMask, + std::int32_t turn) { + SystemStampResult r; + r.stamp = currentStamp; + if (activeMask == 0) return r; // nobody is watching; the old stamp stands + r.stamp = turn; + r.changed = (turn != currentStamp); + return r; +} + +ObservationUpdate UpdateObservations(const std::vector& existing, + const SystemMasks& masks, + const std::vector& slotToId, + std::int32_t encounterId, std::int32_t turn) { + ObservationUpdate up; + up.records = existing; + + const int slotCount = static_cast(slotToId.size()); + for (int slot = 0; slot < slotCount; ++slot) { + if (!IsObservedBy(masks, slot)) continue; + + auto it = std::find_if(up.records.begin(), up.records.end(), + [slot](const Observation& o) { return o.playerSlot == slot; }); + if (it == up.records.end()) { + Observation o; + o.playerSlot = slot; + o.playerId = slotToId[static_cast(slot)]; + o.turnSeen = turn; + o.encounterId = encounterId; + up.records.push_back(o); + ++up.created; + ++up.refreshed; + continue; + } + // The encounter id is a property of the system, fixed for the life of the game, so a + // record that already carries one keeps it. Re-deriving it here from what happens to + // be at the system this turn would be a different rule with the same result on every + // save we hold, and it would silently disagree the first time an encounter dies. + const std::int32_t keptEncounter = + it->encounterId != kNoEncounter ? it->encounterId : encounterId; + if (it->turnSeen == turn && it->encounterId == keptEncounter) { + ++up.alreadyCurrent; + } else { + it->turnSeen = turn; + it->encounterId = keptEncounter; + ++up.refreshed; + } + } + up.untouched = static_cast(up.records.size()) - up.refreshed - up.alreadyCurrent; + + std::stable_sort(up.records.begin(), up.records.end(), + [](const Observation& a, const Observation& b) { + return a.playerSlot < b.playerSlot; + }); + return up; +} + +bool ShouldShareObservation(const Observation* from, const Observation* to) { + if (from == nullptr) return false; + if (to == nullptr) return true; + return to->turnSeen < from->turnSeen; +} + +} // namespace sots::sim diff --git a/src/game/sim/visibility.h b/src/game/sim/visibility.h new file mode 100644 index 0000000..d5d2282 --- /dev/null +++ b/src/game/sim/visibility.h @@ -0,0 +1,146 @@ +// Per-system visibility: who can see a system this turn, what they remember about it, and +// the two turn stamps that record it. +// +// The game keeps three per-(system, player) memories side by side on the star system -- +// what the player last saw of the OWNER, what they last saw of the ENCOUNTER there, and +// what they last saw of the colony's NUMBERS. This module models the second, plus the two +// masks and the system-level stamp that move with it. It is pure: no save shapes, no I/O. +// +// The load-bearing fact, and the one the corpus can check, is the GATE. Four per-player +// masks live on a star system and three of them are equal on every system of every save +// this project holds, so a model built on the wrong one looks correct until it does not. +// +// presence -- the player has a fleet at the system +// gate -- the player has a gate there +// active -- the derived union `presence | gate | isOwner`, recomputed on every fleet +// arrival and departure, so it FALLS when the last fleet leaves +// seen -- the same union but sticky: only ever OR'd, never cleared +// explored -- ever surveyed +// +// The observation record is refreshed under `active`, not under `seen`. One save in the +// corpus separates them: a system whose last fleet left carries `seen` set, `active` clear, +// and a stamp frozen at the previous turn. That save is the test. +#pragma once + +#include +#include + +namespace sots::sim { + +// --------------------------------------------------------------------------------------- +// The masks +// --------------------------------------------------------------------------------------- + +// The per-player masks a star system carries. Bit index is the player's slot index, not its +// handle id, and not its position in the server's player vector. +struct SystemMasks { + std::int32_t seen = 0; // sticky union + std::int32_t explored = 0; // ever surveyed + std::int32_t active = 0; // presence | gate | isOwner, recomputed, non-sticky + std::int32_t presence = 0; // a fleet is here + std::int32_t gate = 0; // a gate is here +}; + +// Bit for a player slot. Slots at or above 32 cannot be represented; the original packs the +// same masks into a 32-bit int and separately caps a runtime companion at 15 players, so a +// slot outside the range is not a case this can encode and the caller is told so. +constexpr bool SlotRepresentable(int playerSlot) { return playerSlot >= 0 && playerSlot < 32; } +constexpr std::int32_t SlotBit(int playerSlot) { + return SlotRepresentable(playerSlot) ? static_cast(1u << playerSlot) : 0; +} + +// Is the system currently observed by this player? This is the gate on the observation +// record and on the system stamp: the ACTIVE mask, not the sticky one. +constexpr bool IsObservedBy(const SystemMasks& m, int playerSlot) { + return SlotRepresentable(playerSlot) && (m.active & SlotBit(playerSlot)) != 0; +} + +// Is the system explored by this player? +constexpr bool IsExploredBy(const SystemMasks& m, int playerSlot) { + return SlotRepresentable(playerSlot) && (m.explored & SlotBit(playerSlot)) != 0; +} + +// The active mask as the original recomputes it on every fleet arrival and departure. Kept +// here so the invariant is stated in one place even though the standalone reads the mask +// from the save rather than rebuilding it -- the two reference pairs move no mask leaf, so +// the loaded value is the value the phase would see, and a rebuild would be a change with +// no evidence behind it. +constexpr std::int32_t RecomputeActive(std::int32_t presence, std::int32_t gate, + std::int32_t ownerBit) { + return presence | gate | ownerBit; +} + +// The explored sweep, once per turn: every player who can currently see the system has now +// surveyed it. Returns the new mask; the caller compares to know whether a leaf moved. +constexpr std::int32_t ApplyExploredSweep(std::int32_t explored, std::int32_t active) { + return explored | active; +} + +// Which slots this sweep newly sets -- the ones that would each post an "explored" event. +std::vector NewlyExploredSlots(std::int32_t explored, std::int32_t active); + +// --------------------------------------------------------------------------------------- +// The system-level stamp +// --------------------------------------------------------------------------------------- + +// The system's own "last observed" turn. Written when ANY player can see it -- the gate is +// the whole active mask being non-zero, not a particular player's bit -- and left alone +// otherwise, so a system nobody watches keeps the turn it was last watched. +struct SystemStampResult { + std::int32_t stamp = 0; + bool changed = false; +}; +SystemStampResult UpdateSystemStamp(std::int32_t currentStamp, std::int32_t activeMask, + std::int32_t turn); + +// --------------------------------------------------------------------------------------- +// The per-(system, player) observation record +// --------------------------------------------------------------------------------------- + +// No encounter is present. The system field this mirrors is constructed to this value and +// the map lookup returns it for a player who has no record. +constexpr std::int32_t kNoEncounter = -1; + +// One remembered sighting. `playerSlot` is the map key and orders the list; `playerId` is +// what the wire carries in its place. +struct Observation { + int playerSlot = 0; + std::int32_t playerId = 0; + std::int32_t turnSeen = 0; + std::int32_t encounterId = kNoEncounter; +}; + +// What one system's observation list should be after this turn's pass. +// +// `existing` is the list as loaded, `encounterId` the encounter parked at the system, and +// `slotToId` maps a player slot to the handle id the wire carries. A player who can see the +// system gets a refreshed record; a player who cannot keeps whatever it had. Nothing is ever +// removed: the original's map has exactly three callers of `operator[]` and none of them +// erases, which is what makes this a memory rather than a state. +// +// The result is ordered by player slot ascending, because the original stores it in a tree +// keyed by that slot. Every system in the corpus has at most one entry, so the ordering is +// asserted from the container and not from evidence. +struct ObservationUpdate { + std::vector records; + int refreshed = 0; // records whose stamp was rewritten + int created = 0; // of those, ones that did not exist before + int alreadyCurrent = 0; // observed this turn and already carrying this turn's stamp + int untouched = 0; // not observed this turn; kept exactly as loaded +}; + +ObservationUpdate UpdateObservations(const std::vector& existing, + const SystemMasks& masks, + const std::vector& slotToId, + std::int32_t encounterId, std::int32_t turn); + +// The intel-sharing rule: a player may be handed another player's record when the other's +// sighting is newer, or when it has none of its own. The stamp that is copied is the +// SIGHTING stamp, not the current turn -- the receiver learns what the donor saw, and when. +// +// Nothing in the corpus has ever executed this: no save has two players in an alliance. It +// is here because the rule is short, it is read, and leaving it out would misrepresent the +// record as single-writer. +bool ShouldShareObservation(const Observation* from, const Observation* to); + +} // namespace sots::sim diff --git a/tests/game_sim/CMakeLists.txt b/tests/game_sim/CMakeLists.txt index 3ba54f5..123a95e 100644 --- a/tests/game_sim/CMakeLists.txt +++ b/tests/game_sim/CMakeLists.txt @@ -1,5 +1,5 @@ # game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON). -foreach(_t economy research colony movement techgraph) +foreach(_t economy research colony movement techgraph visibility) add_executable(game_sim_test_${_t} test_${_t}.cpp) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tests/game_sim/test_visibility.cpp b/tests/game_sim/test_visibility.cpp new file mode 100644 index 0000000..39e253c --- /dev/null +++ b/tests/game_sim/test_visibility.cpp @@ -0,0 +1,203 @@ +#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"); +}