sots-engine/src/app/visibility_phase.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

175 lines
6.3 KiB
C++

#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