merge lane E3: nve visibility record; 46 leaves closed 0 regressed; the gate is AFlags

This commit is contained in:
alex 2026-09-08 13:40:06 -04:00
commit c51b8d0c39
10 changed files with 696 additions and 5 deletions

View file

@ -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}/..)

View file

@ -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"},

View file

@ -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);
}

View file

@ -0,0 +1,175 @@
#include "app/visibility_phase.h"
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <string>
#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<std::int32_t> 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<std::int32_t> out(static_cast<std::size_t>(maxSlot) + 1, 0);
for (const auto& e : game.sim.players) {
if (!sim::SlotRepresentable(e.player.plyrIdx)) continue;
out[static_cast<std::size_t>(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<int>(
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<std::int32_t> slotToId = SlotToPlayerId(game);
int created = 0, refreshed = 0, derivedEncounters = 0;
for (auto& e : game.sim.systems) {
++r.systemsVisited;
std::vector<sim::Observation> 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<int>(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

View file

@ -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 <string>
#include <vector>
#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<std::string> 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

View file

@ -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)

View file

@ -0,0 +1,78 @@
#include "game/sim/visibility.h"
#include <algorithm>
namespace sots::sim {
std::vector<int> NewlyExploredSlots(std::int32_t explored, std::int32_t active) {
std::vector<int> out;
const std::uint32_t newly = static_cast<std::uint32_t>(active) & ~static_cast<std::uint32_t>(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<Observation>& existing,
const SystemMasks& masks,
const std::vector<std::int32_t>& slotToId,
std::int32_t encounterId, std::int32_t turn) {
ObservationUpdate up;
up.records = existing;
const int slotCount = static_cast<int>(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<std::size_t>(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<int>(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

146
src/game/sim/visibility.h Normal file
View file

@ -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 <cstdint>
#include <vector>
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<std::int32_t>(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<int> 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<Observation> 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<Observation>& existing,
const SystemMasks& masks,
const std::vector<std::int32_t>& 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

View file

@ -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})

View file

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