merge lane E3: nve visibility record; 46 leaves closed 0 regressed; the gate is AFlags
This commit is contained in:
commit
c51b8d0c39
10 changed files with 696 additions and 5 deletions
|
|
@ -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}/..)
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
175
src/app/visibility_phase.cpp
Normal file
175
src/app/visibility_phase.cpp
Normal 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
|
||||
55
src/app/visibility_phase.h
Normal file
55
src/app/visibility_phase.h
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
78
src/game/sim/visibility.cpp
Normal file
78
src/game/sim/visibility.cpp
Normal 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
146
src/game/sim/visibility.h
Normal 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
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
203
tests/game_sim/test_visibility.cpp
Normal file
203
tests/game_sim/test_visibility.cpp
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue