Merge branch 'wip/dt'
This commit is contained in:
commit
7741d42fc5
9 changed files with 554 additions and 2 deletions
|
|
@ -12,6 +12,7 @@ add_library(sots_app STATIC
|
||||||
construction_phase.cpp
|
construction_phase.cpp
|
||||||
event_phase.cpp
|
event_phase.cpp
|
||||||
growth_phase.cpp
|
growth_phase.cpp
|
||||||
|
observed_phase.cpp
|
||||||
script_phase.cpp
|
script_phase.cpp
|
||||||
visibility_phase.cpp
|
visibility_phase.cpp
|
||||||
command_replay.cpp
|
command_replay.cpp
|
||||||
|
|
|
||||||
172
src/app/observed_phase.cpp
Normal file
172
src/app/observed_phase.cpp
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
#include "app/observed_phase.h"
|
||||||
|
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include "game/sim/observed.h"
|
||||||
|
|
||||||
|
namespace sots::app {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using mars::stream::shapes::Odes;
|
||||||
|
using mars::stream::shapes::SaveGame;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which player owns which design, and whether that owner's designs can be observed at all.
|
||||||
|
struct DesignOwner {
|
||||||
|
std::int32_t ownerId = 0;
|
||||||
|
bool observable = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unordered_map<std::int32_t, DesignOwner> DesignOwners(const SaveGame& game) {
|
||||||
|
std::unordered_map<std::int32_t, DesignOwner> out;
|
||||||
|
for (const auto& e : game.sim.players) {
|
||||||
|
const bool observable =
|
||||||
|
sim::DesignOwnerIsObservable(e.player.npc, e.player.rebAI);
|
||||||
|
for (const auto& d : e.player.designs) {
|
||||||
|
DesignOwner o;
|
||||||
|
o.ownerId = e.playerID;
|
||||||
|
o.observable = observable;
|
||||||
|
out[d.desID] = o;
|
||||||
|
}
|
||||||
|
// A legacy design is still a design the game can hand a ship; treat it the same, but
|
||||||
|
// never let it shadow a live one.
|
||||||
|
for (const auto& d : e.player.legacyDesigns) {
|
||||||
|
if (out.count(d.desID)) continue;
|
||||||
|
DesignOwner o;
|
||||||
|
o.ownerId = e.playerID;
|
||||||
|
o.observable = observable;
|
||||||
|
out[d.desID] = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<sim::ObservedDesign> FromWire(const std::vector<Odes>& in) {
|
||||||
|
std::vector<sim::ObservedDesign> out;
|
||||||
|
out.reserve(in.size());
|
||||||
|
for (const auto& o : in) {
|
||||||
|
sim::ObservedDesign r;
|
||||||
|
r.turnFirst = o.ontF;
|
||||||
|
r.turnLast = o.otnL;
|
||||||
|
r.designId = o.odid;
|
||||||
|
r.ownerId = o.opid;
|
||||||
|
out.push_back(r);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves that move, counted the way state_checksum counts them: the array's own count leaf
|
||||||
|
// when the length changes, then one per differing field of each surviving position, then the
|
||||||
|
// whole record for a position only one side has.
|
||||||
|
int LeafDelta(const std::vector<Odes>& before, const std::vector<sim::ObservedDesign>& after) {
|
||||||
|
int moved = 0;
|
||||||
|
if (before.size() != after.size()) ++moved;
|
||||||
|
const std::size_t common = before.size() < after.size() ? before.size() : after.size();
|
||||||
|
for (std::size_t i = 0; i < common; ++i) {
|
||||||
|
if (before[i].ontF != after[i].turnFirst) ++moved;
|
||||||
|
if (before[i].otnL != after[i].turnLast) ++moved;
|
||||||
|
if (before[i].odid != after[i].designId) ++moved;
|
||||||
|
if (before[i].opid != after[i].ownerId) ++moved;
|
||||||
|
}
|
||||||
|
moved += static_cast<int>((before.size() > after.size() ? before.size() - after.size()
|
||||||
|
: after.size() - before.size())) * 4;
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ObservedDesignsResult RunRecordObservedDesigns(SaveGame& game) {
|
||||||
|
ObservedDesignsResult r;
|
||||||
|
const std::unordered_map<std::int32_t, DesignOwner> owners = DesignOwners(game);
|
||||||
|
const std::int32_t turn = game.sim.frame;
|
||||||
|
|
||||||
|
int unknownDesigns = 0;
|
||||||
|
for (auto& pe : game.sim.players) {
|
||||||
|
if (!sim::ObserverSlotVisited(pe.player.plyrIdx)) {
|
||||||
|
++r.playersSkipped;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++r.playersSwept;
|
||||||
|
|
||||||
|
std::vector<sim::ObservedDesign> list = FromWire(pe.player.odes);
|
||||||
|
|
||||||
|
for (const auto& fe : game.sim.fleets) {
|
||||||
|
for (const auto& se : fe.flt.ships) {
|
||||||
|
// The stand-in for the ship's unserialised per-player visibility word.
|
||||||
|
if (se.ship.plrID != pe.playerID) continue;
|
||||||
|
++r.shipsOffered;
|
||||||
|
|
||||||
|
const auto it = owners.find(se.ship.desID);
|
||||||
|
if (it == owners.end()) {
|
||||||
|
++unknownDesigns;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!it->second.observable) {
|
||||||
|
++r.npcDesignsSkipped;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++r.recordCalls;
|
||||||
|
const sim::RecordObservedDesignResult rec =
|
||||||
|
sim::RecordObservedDesign(list, se.ship.desID, it->second.ownerId, turn);
|
||||||
|
if (rec.created) ++r.created;
|
||||||
|
if (rec.moved) ++r.moved;
|
||||||
|
r.evicted += rec.evicted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int moved = LeafDelta(pe.player.odes, list);
|
||||||
|
if (moved == 0) continue;
|
||||||
|
|
||||||
|
std::vector<Odes> out;
|
||||||
|
out.reserve(list.size());
|
||||||
|
for (std::size_t i = 0; i < list.size(); ++i) {
|
||||||
|
// Carry the existing element's opaque tail where there is one, so a record the
|
||||||
|
// save wrote with extra items round-trips; a created record has none.
|
||||||
|
Odes o;
|
||||||
|
if (i < pe.player.odes.size()) o.extra = pe.player.odes[i].extra;
|
||||||
|
o.ontF = list[i].turnFirst;
|
||||||
|
o.otnL = list[i].turnLast;
|
||||||
|
o.odid = list[i].designId;
|
||||||
|
o.opid = list[i].ownerId;
|
||||||
|
out.push_back(o);
|
||||||
|
}
|
||||||
|
pe.player.odes = out;
|
||||||
|
++r.playersTouched;
|
||||||
|
r.leafWrites += moved;
|
||||||
|
}
|
||||||
|
|
||||||
|
r.notes.push_back(fmt("%d player(s) swept, %d skipped (slot >= %d); %d (player, ship) "
|
||||||
|
"pair(s) offered, %d recorded",
|
||||||
|
r.playersSwept, r.playersSkipped, sim::kObserverSlotLimit,
|
||||||
|
r.shipsOffered, r.recordCalls));
|
||||||
|
r.notes.push_back(fmt("%d record(s) created, %d moved to the back, %d evicted by the "
|
||||||
|
"%d-per-owner cap, over %d player(s); %d leaf/leaves",
|
||||||
|
r.created, r.moved, r.evicted, sim::kObservedDesignsPerOwner,
|
||||||
|
r.playersTouched, r.leafWrites));
|
||||||
|
if (r.npcDesignsSkipped)
|
||||||
|
r.notes.push_back(fmt("%d ship(s) carry a design owned by a non-rebel NPC, which is "
|
||||||
|
"never observed by anyone -- the guard is on the DESIGN'S "
|
||||||
|
"OWNER, not on the observer",
|
||||||
|
r.npcDesignsSkipped));
|
||||||
|
if (unknownDesigns)
|
||||||
|
r.notes.push_back(fmt("%d ship(s) name a design no player's list carries; skipped, "
|
||||||
|
"because the record needs the design's owner",
|
||||||
|
unknownDesigns));
|
||||||
|
r.notes.push_back("the gate is a HYPOTHESIS: the original tests a two-bit-per-player word "
|
||||||
|
"on the ship that is NOT serialised, and this phase stands it in with "
|
||||||
|
"ownership. No save in the corpus separates the two -- on both reference "
|
||||||
|
"pairs every observation that moves is a player's own design");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace sots::app
|
||||||
47
src/app/observed_phase.h
Normal file
47
src/app/observed_phase.h
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// T34 RecordObservedDesigns, wired to the save shapes.
|
||||||
|
//
|
||||||
|
// The original's sweep is `for each player, for each fleet, for each ship of that fleet`,
|
||||||
|
// with one gate per (player, ship) pair, and it calls the recorder once per SHIP -- not once
|
||||||
|
// per design. The repetition is load-bearing: the recorder moves a re-observed record to the
|
||||||
|
// back of the list, so the final order is the order of each design's LAST ship in the sweep,
|
||||||
|
// and the per-owner cap is applied after every call, so a player with more than twenty
|
||||||
|
// designs in service evicts and re-creates records inside the one sweep.
|
||||||
|
//
|
||||||
|
// THE GATE IS NOT ON THE WIRE, and this is the single hypothesis in the phase. The original
|
||||||
|
// tests a two-bit-per-player field on the ship (index 2*playerSlot, both bits required); the
|
||||||
|
// ship's serialised field list does not carry that word. This phase stands it in with
|
||||||
|
// OWNERSHIP -- a player is offered its own ships and nothing else -- which is the smallest
|
||||||
|
// rule that is correct on every state the corpus contains: on both reference pairs no player
|
||||||
|
// observes another player's ship, so no save can separate the two rules. The falsifier is
|
||||||
|
// named and cheap: the first save in which one empire's fleet stands in another's sensor
|
||||||
|
// range AND a foreign observation record moves.
|
||||||
|
//
|
||||||
|
// What IS read from the binary, and what makes this phase more than a guess, is in
|
||||||
|
// `game/sim/observed.h`: the dedup key, the erase-and-re-append, and the 20-per-owner cap.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "mars/stream/shapes.h"
|
||||||
|
|
||||||
|
namespace sots::app {
|
||||||
|
|
||||||
|
struct ObservedDesignsResult {
|
||||||
|
int playersSwept = 0; // players the sweep visited (slot < 15)
|
||||||
|
int playersSkipped = 0; // players whose slot is at or above the sweep's limit
|
||||||
|
int shipsOffered = 0; // (player, ship) pairs that passed the gate
|
||||||
|
int recordCalls = 0; // calls into the recorder -- one per offered ship
|
||||||
|
int created = 0; // records appended for a design not previously in the list
|
||||||
|
int moved = 0; // records erased and re-appended
|
||||||
|
int evicted = 0; // records the per-owner cap dropped
|
||||||
|
int npcDesignsSkipped = 0; // ships whose design belongs to a non-rebel NPC
|
||||||
|
int leafWrites = 0; // save leaves this phase changed
|
||||||
|
int playersTouched = 0;
|
||||||
|
std::vector<std::string> notes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// T34: refresh every player's observed-design list from the ships it can see.
|
||||||
|
ObservedDesignsResult RunRecordObservedDesigns(mars::stream::shapes::SaveGame& game);
|
||||||
|
|
||||||
|
} // namespace sots::app
|
||||||
|
|
@ -333,7 +333,35 @@ constexpr PhaseDesc kTail[] = {
|
||||||
"is a fact only a running process could supply"},
|
"is a fact only a running process could supply"},
|
||||||
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
|
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
|
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Partial,
|
||||||
|
"refreshes every player's observed-DESIGN list from the ships it can see. The sweep is "
|
||||||
|
"for each player, over every ship of every fleet -- once per SHIP, not once per design "
|
||||||
|
"-- and three of the recorder's rules are invisible from the save and all three change "
|
||||||
|
"the answer: the lookup key is the design id ALONE, not the (design, owner) pair; a "
|
||||||
|
"re-observation ERASES the record and PUSHES A COPY ON THE BACK rather than updating it "
|
||||||
|
"in place, so the list ends up in last-observation order and the record's first-seen "
|
||||||
|
"turn survives the move; and the list is CAPPED AT 20 RECORDS PER DESIGN OWNER, counted "
|
||||||
|
"from the most recent end, with the cap applied after EVERY record call rather than once "
|
||||||
|
"per sweep -- which on an empire with more than twenty designs in service evicts and "
|
||||||
|
"re-creates records inside the one sweep and resets their first-seen turn. A fourth rule "
|
||||||
|
"is a guard on the DESIGN'S OWNER, not on the observer: a design owned by an NPC that is "
|
||||||
|
"not a rebel AI is never observed by anyone, and every observation record in every "
|
||||||
|
"corpus save agrees -- the NPC factions' designs appear nowhere. TWO THINGS ARE NOT "
|
||||||
|
"MODELLED AND BOTH ARE NAMED. (1) The GATE is a two-bit-per-player word on the ship that "
|
||||||
|
"is NOT serialised; this phase stands it in with ownership, which is the smallest rule "
|
||||||
|
"correct on every state the corpus holds, because on both reference pairs no player "
|
||||||
|
"observes another player's ship. It is a HYPOTHESIS and its falsifier is one save in "
|
||||||
|
"which a foreign observation record moves. (2) The phase's TECH and WEAPON arms are not "
|
||||||
|
"implemented: the original feeds the design through three set builders into the observed-"
|
||||||
|
"tech and observed-weapon lists, and those builders are not decoded. The wire's own "
|
||||||
|
"per-section option-tech list covers only 13 of the 18 tech names the reference rich "
|
||||||
|
"turn moves, so it is one of the three sources and not all of them. Measured: on the "
|
||||||
|
"canonical pair 1 leaf closed / 0 regressed; on the rich turn the design half closes 34 "
|
||||||
|
"of its 55 and the 21 that remain are the shadow of ship construction -- two designs "
|
||||||
|
"receive their first ships during that turn, and no phase in this engine builds a ship, "
|
||||||
|
"so those two observations cannot happen and the two evictions they would have caused do "
|
||||||
|
"not happen either. Fed the true post-turn ship list instead, the same code leaves 1 leaf "
|
||||||
|
"of 55, so the mechanism is not the residual"},
|
||||||
{Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
|
{Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""},
|
||||||
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked,
|
{Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked,
|
||||||
"fills every player's turn record and archives it by turn; must stay last. Thirteen of "
|
"fills every player's turn record and archives it by turn; must stay last. Thirteen of "
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
#include "app/construction_phase.h"
|
#include "app/construction_phase.h"
|
||||||
#include "app/event_phase.h"
|
#include "app/event_phase.h"
|
||||||
#include "app/growth_phase.h"
|
#include "app/growth_phase.h"
|
||||||
|
#include "app/observed_phase.h"
|
||||||
#include "app/script_phase.h"
|
#include "app/script_phase.h"
|
||||||
#include "app/trade_raid.h"
|
#include "app/trade_raid.h"
|
||||||
#include "app/treaty.h"
|
#include "app/treaty.h"
|
||||||
|
|
@ -1360,6 +1361,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
rec.leafWrites = s.leafWrites;
|
rec.leafWrites = s.leafWrites;
|
||||||
rec.committed = s.leafWrites > 0;
|
rec.committed = s.leafWrites > 0;
|
||||||
rec.notes = s.notes;
|
rec.notes = s.notes;
|
||||||
|
} else if (tp[i].index == 34) {
|
||||||
|
const ObservedDesignsResult o = RunRecordObservedDesigns(game);
|
||||||
|
rec.invocations = o.recordCalls;
|
||||||
|
rec.leafWrites = o.leafWrites;
|
||||||
|
rec.committed = o.leafWrites > 0;
|
||||||
|
rec.notes = o.notes;
|
||||||
} else if (tp[i].index == 31) {
|
} else if (tp[i].index == 31) {
|
||||||
RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns);
|
RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns);
|
||||||
} else if (tp[i].index == 36) {
|
} else if (tp[i].index == 36) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ add_library(sots_game_sim STATIC
|
||||||
colony.cpp
|
colony.cpp
|
||||||
movement.cpp
|
movement.cpp
|
||||||
visibility.cpp
|
visibility.cpp
|
||||||
|
observed.cpp
|
||||||
scriptobjects.cpp
|
scriptobjects.cpp
|
||||||
techgraph.cpp
|
techgraph.cpp
|
||||||
player_turn.cpp)
|
player_turn.cpp)
|
||||||
|
|
@ -21,7 +22,7 @@ endif()
|
||||||
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
|
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
|
||||||
if(SOTS_GAME_SIM_TESTS)
|
if(SOTS_GAME_SIM_TESTS)
|
||||||
enable_testing()
|
enable_testing()
|
||||||
set(_sim_tests economy research colony movement techgraph visibility construction player_turn scriptobjects)
|
set(_sim_tests economy research colony movement techgraph visibility observed construction player_turn scriptobjects)
|
||||||
foreach(_t IN LISTS _sim_tests)
|
foreach(_t IN LISTS _sim_tests)
|
||||||
add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp)
|
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)
|
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
|
||||||
|
|
|
||||||
55
src/game/sim/observed.cpp
Normal file
55
src/game/sim/observed.cpp
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#include "game/sim/observed.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace sots::sim {
|
||||||
|
|
||||||
|
RecordObservedDesignResult RecordObservedDesign(std::vector<ObservedDesign>& list,
|
||||||
|
std::int32_t designId, std::int32_t ownerId,
|
||||||
|
std::int32_t turn) {
|
||||||
|
RecordObservedDesignResult r;
|
||||||
|
|
||||||
|
// The search key is the design id alone. The owner is written into a new record and is
|
||||||
|
// read by the cap below, but it is not part of the lookup -- matching the original,
|
||||||
|
// which compares one word.
|
||||||
|
const auto it = std::find_if(list.begin(), list.end(),
|
||||||
|
[designId](const ObservedDesign& o) { return o.designId == designId; });
|
||||||
|
|
||||||
|
if (it == list.end()) {
|
||||||
|
ObservedDesign rec;
|
||||||
|
rec.turnFirst = turn;
|
||||||
|
rec.designId = designId;
|
||||||
|
rec.ownerId = ownerId;
|
||||||
|
list.push_back(rec);
|
||||||
|
r.created = true;
|
||||||
|
} else {
|
||||||
|
// Erase and re-append, carrying the record's own fields. `turnFirst` therefore
|
||||||
|
// survives a re-observation, and the list order becomes last-observation order.
|
||||||
|
const ObservedDesign carried = *it;
|
||||||
|
list.erase(it);
|
||||||
|
list.push_back(carried);
|
||||||
|
r.moved = true;
|
||||||
|
}
|
||||||
|
list.back().turnLast = turn;
|
||||||
|
|
||||||
|
// The cap, applied here and not once per sweep: walk from the most recent end, count the
|
||||||
|
// records belonging to this design's owner, and drop everything past the twentieth. Other
|
||||||
|
// owners' records are stepped over, not counted -- the bucket is per owner.
|
||||||
|
int kept = 0;
|
||||||
|
for (std::size_t i = list.size(); i-- > 0;) {
|
||||||
|
if (list[i].ownerId != ownerId) continue;
|
||||||
|
if (kept >= kObservedDesignsPerOwner) {
|
||||||
|
r.evictedIds.push_back(list[i].designId);
|
||||||
|
list.erase(list.begin() + static_cast<std::ptrdiff_t>(i));
|
||||||
|
++r.evicted;
|
||||||
|
} else {
|
||||||
|
++kept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The walk is back-to-front, so the ids came out newest-evicted first; report them the
|
||||||
|
// way the list reads.
|
||||||
|
std::reverse(r.evictedIds.begin(), r.evictedIds.end());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace sots::sim
|
||||||
83
src/game/sim/observed.h
Normal file
83
src/game/sim/observed.h
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// The per-player record of which SHIP DESIGNS an empire has observed.
|
||||||
|
//
|
||||||
|
// A player carries three parallel observation lists -- designs, weapons and technologies.
|
||||||
|
// This module models the first. Its shape is a plain vector of
|
||||||
|
// `{turnFirst, turnLast, designId, ownerId}`, and everything interesting about it is in how
|
||||||
|
// the vector is MAINTAINED, because three of those rules are invisible from the save alone
|
||||||
|
// and all three change the answer:
|
||||||
|
//
|
||||||
|
// * the lookup key is the DESIGN ID ALONE, not the (design, owner) pair;
|
||||||
|
// * a re-observation ERASES the existing record and PUSHES A COPY ON THE BACK -- it does
|
||||||
|
// not update in place. So the list is ordered by LAST observation, and `turnFirst`
|
||||||
|
// survives the move. This is why a turn's output is a permutation of its input rather
|
||||||
|
// than an append;
|
||||||
|
// * the list is CAPPED AT 20 RECORDS PER DESIGN OWNER, counted from the most recent end,
|
||||||
|
// and the cap is applied after EVERY SINGLE record call rather than once per sweep. On
|
||||||
|
// an empire with more than twenty designs in service that thrashes inside one sweep:
|
||||||
|
// records are evicted and re-created, and a re-created record's `turnFirst` is reset to
|
||||||
|
// the current turn. A model that caps once at the end produces a different list.
|
||||||
|
//
|
||||||
|
// The gate -- which (player, ship) pairs are offered to `Record` at all -- is NOT here. It
|
||||||
|
// lives on the ship, in a two-bit-per-player field that is not serialised, and the caller
|
||||||
|
// stands in for it. See `app/observed_phase.h`.
|
||||||
|
//
|
||||||
|
// Pure: no save shapes, no I/O.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace sots::sim {
|
||||||
|
|
||||||
|
// The cap the original enforces, per design owner, counted from the back of the list.
|
||||||
|
inline constexpr int kObservedDesignsPerOwner = 20;
|
||||||
|
|
||||||
|
// The runtime companion of the per-player masks is capped at fifteen players, and the
|
||||||
|
// design-observation sweep skips any player whose slot is at or above it. Slots are the
|
||||||
|
// player's index, not its handle id.
|
||||||
|
inline constexpr int kObserverSlotLimit = 15;
|
||||||
|
|
||||||
|
struct ObservedDesign {
|
||||||
|
std::int32_t turnFirst = 0; // `otnF` -- the turn this record was created
|
||||||
|
std::int32_t turnLast = 0; // `otnL` -- the turn it was last refreshed
|
||||||
|
std::int32_t designId = 0; // `odid`
|
||||||
|
std::int32_t ownerId = 0; // `opid` -- the handle id of the design's owning player
|
||||||
|
|
||||||
|
bool operator==(const ObservedDesign& o) const {
|
||||||
|
return turnFirst == o.turnFirst && turnLast == o.turnLast && designId == o.designId &&
|
||||||
|
ownerId == o.ownerId;
|
||||||
|
}
|
||||||
|
bool operator!=(const ObservedDesign& o) const { return !(*this == o); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// What one `Record` call did, so a caller can report mechanism rather than a leaf count.
|
||||||
|
struct RecordObservedDesignResult {
|
||||||
|
bool created = false; // the design was not in the list and a record was appended
|
||||||
|
bool moved = false; // it was in the list and was erased and re-appended
|
||||||
|
int evicted = 0; // records the cap dropped on this call
|
||||||
|
// The design ids the cap dropped, oldest first. A caller that models the original's
|
||||||
|
// "forgotten design" notification needs them; nothing on the wire does.
|
||||||
|
std::vector<std::int32_t> evictedIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record one observation. `turn` is the turn being recorded (`otnL`, and `otnF` on a
|
||||||
|
// create). `ownerId` is the design's owner, which decides both the record's `opid` and which
|
||||||
|
// cap bucket the trim counts.
|
||||||
|
RecordObservedDesignResult RecordObservedDesign(std::vector<ObservedDesign>& list,
|
||||||
|
std::int32_t designId, std::int32_t ownerId,
|
||||||
|
std::int32_t turn);
|
||||||
|
|
||||||
|
// Is this player's slot one the sweep visits at all?
|
||||||
|
constexpr bool ObserverSlotVisited(int playerSlot) {
|
||||||
|
return playerSlot >= 0 && playerSlot < kObserverSlotLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guard the original applies to the DESIGN'S OWNER before recording anything: a design
|
||||||
|
// owned by an NPC that is not a rebel AI is never observed by anyone. Measured against the
|
||||||
|
// whole save corpus: every observation record in every save names a design owned by one of
|
||||||
|
// the two non-NPC empires, and the four NPC factions' twenty-six designs appear nowhere.
|
||||||
|
constexpr bool DesignOwnerIsObservable(bool ownerIsNpc, bool ownerIsRebelAi) {
|
||||||
|
return !ownerIsNpc || ownerIsRebelAi;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace sots::sim
|
||||||
158
tests/game_sim/test_observed.cpp
Normal file
158
tests/game_sim/test_observed.cpp
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
#include "game/sim/observed.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "check.h"
|
||||||
|
|
||||||
|
using namespace sots::sim;
|
||||||
|
|
||||||
|
static ObservedDesign rec(std::int32_t first, std::int32_t last, std::int32_t design,
|
||||||
|
std::int32_t owner) {
|
||||||
|
ObservedDesign o;
|
||||||
|
o.turnFirst = first;
|
||||||
|
o.turnLast = last;
|
||||||
|
o.designId = design;
|
||||||
|
o.ownerId = owner;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<std::int32_t> ids(const std::vector<ObservedDesign>& v) {
|
||||||
|
std::vector<std::int32_t> out;
|
||||||
|
for (const auto& o : v) out.push_back(o.designId);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_create() {
|
||||||
|
std::vector<ObservedDesign> l;
|
||||||
|
const auto r = RecordObservedDesign(l, /*design*/ 18, /*owner*/ 32, /*turn*/ 3);
|
||||||
|
CHECK(r.created);
|
||||||
|
CHECK(!r.moved);
|
||||||
|
CHECK_EQ(r.evicted, 0);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{1});
|
||||||
|
CHECK_EQ(l[0].turnFirst, 3);
|
||||||
|
CHECK_EQ(l[0].turnLast, 3);
|
||||||
|
CHECK_EQ(l[0].designId, 18);
|
||||||
|
CHECK_EQ(l[0].ownerId, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the record: a second sighting stamps `turnLast` and leaves `turnFirst`
|
||||||
|
// where it was. If this ever regresses, every leaf in the list moves.
|
||||||
|
static void test_refresh_keeps_first_seen() {
|
||||||
|
std::vector<ObservedDesign> l{rec(2, 2, 18, 32)};
|
||||||
|
const auto r = RecordObservedDesign(l, 18, 32, 3);
|
||||||
|
CHECK(!r.created);
|
||||||
|
CHECK(r.moved);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{1});
|
||||||
|
CHECK_EQ(l[0].turnFirst, 2);
|
||||||
|
CHECK_EQ(l[0].turnLast, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-observation is erase + push_back, NOT an in-place update, so the list ends up in
|
||||||
|
// last-observation order. A model that updated in place would leave A B C here.
|
||||||
|
static void test_reobservation_moves_to_the_back() {
|
||||||
|
std::vector<ObservedDesign> l{rec(1, 1, 10, 32), rec(1, 1, 20, 32), rec(1, 1, 30, 32)};
|
||||||
|
RecordObservedDesign(l, 10, 32, 5);
|
||||||
|
CHECK(ids(l) == (std::vector<std::int32_t>{20, 30, 10}));
|
||||||
|
RecordObservedDesign(l, 20, 32, 5);
|
||||||
|
CHECK(ids(l) == (std::vector<std::int32_t>{30, 10, 20}));
|
||||||
|
// and the moved records kept their first-seen turn
|
||||||
|
CHECK_EQ(l[1].turnFirst, 1);
|
||||||
|
CHECK_EQ(l[2].turnFirst, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lookup key is the design id ALONE. Two owners cannot normally share one, but the
|
||||||
|
// original compares a single word and this pins that reading.
|
||||||
|
static void test_key_is_the_design_id_alone() {
|
||||||
|
std::vector<ObservedDesign> l{rec(1, 1, 10, 32)};
|
||||||
|
const auto r = RecordObservedDesign(l, 10, /*a different owner*/ 16, 5);
|
||||||
|
CHECK(r.moved);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{1});
|
||||||
|
CHECK_EQ(l[0].ownerId, 32); // the carried record keeps its own owner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twenty per owner, counted from the most recent end, so the eviction comes off the FRONT.
|
||||||
|
static void test_cap_is_twenty_per_owner() {
|
||||||
|
std::vector<ObservedDesign> l;
|
||||||
|
for (int i = 0; i < kObservedDesignsPerOwner; ++i)
|
||||||
|
RecordObservedDesign(l, 100 + i, 32, 1);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner});
|
||||||
|
|
||||||
|
const auto r = RecordObservedDesign(l, 999, 32, 2);
|
||||||
|
CHECK(r.created);
|
||||||
|
CHECK_EQ(r.evicted, 1);
|
||||||
|
CHECK_EQ(r.evictedIds.size(), std::size_t{1});
|
||||||
|
CHECK_EQ(r.evictedIds[0], 100); // the oldest went
|
||||||
|
CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner});
|
||||||
|
CHECK_EQ(l.front().designId, 101);
|
||||||
|
CHECK_EQ(l.back().designId, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bucket is per design OWNER: a full list for owner 32 does not evict owner 16's record,
|
||||||
|
// and owner 16's record is stepped over rather than counted.
|
||||||
|
static void test_cap_buckets_by_owner() {
|
||||||
|
std::vector<ObservedDesign> l{rec(1, 1, 7, 16)};
|
||||||
|
for (int i = 0; i < kObservedDesignsPerOwner; ++i)
|
||||||
|
RecordObservedDesign(l, 100 + i, 32, 1);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner + 1});
|
||||||
|
CHECK_EQ(l.front().designId, 7);
|
||||||
|
|
||||||
|
RecordObservedDesign(l, 999, 32, 2);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner + 1});
|
||||||
|
CHECK_EQ(l.front().designId, 7); // still there
|
||||||
|
CHECK_EQ(l[1].designId, 101); // 100 was the one that went
|
||||||
|
|
||||||
|
// and owner 16 can still reach twenty of its own
|
||||||
|
for (int i = 0; i < kObservedDesignsPerOwner - 1; ++i)
|
||||||
|
RecordObservedDesign(l, 200 + i, 16, 3);
|
||||||
|
int sixteens = 0;
|
||||||
|
for (const auto& o : l)
|
||||||
|
if (o.ownerId == 16) ++sixteens;
|
||||||
|
CHECK_EQ(sixteens, kObservedDesignsPerOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cap runs after EVERY call, not once at the end. Twenty-one designs seen in turn, each
|
||||||
|
// once, leaves the LAST twenty -- and if the evicted one is then seen again it comes back as
|
||||||
|
// a NEW record with this turn as its first-seen. That reset is what the reference save shows
|
||||||
|
// on an empire with twenty-eight designs in service.
|
||||||
|
static void test_thrash_resets_first_seen() {
|
||||||
|
std::vector<ObservedDesign> l;
|
||||||
|
for (int i = 0; i <= kObservedDesignsPerOwner; ++i)
|
||||||
|
RecordObservedDesign(l, 100 + i, 32, /*turn*/ 5);
|
||||||
|
CHECK_EQ(l.size(), std::size_t{kObservedDesignsPerOwner});
|
||||||
|
CHECK_EQ(l.front().designId, 101);
|
||||||
|
|
||||||
|
const auto again = RecordObservedDesign(l, 100, 32, 6);
|
||||||
|
CHECK(again.created); // it was evicted, so this is a CREATE
|
||||||
|
CHECK_EQ(l.back().turnFirst, 6); // ... and the first-seen turn is reset
|
||||||
|
CHECK_EQ(again.evicted, 1);
|
||||||
|
CHECK_EQ(again.evictedIds[0], 101);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_slot_limit() {
|
||||||
|
CHECK(ObserverSlotVisited(0));
|
||||||
|
CHECK(ObserverSlotVisited(14));
|
||||||
|
CHECK(!ObserverSlotVisited(15));
|
||||||
|
CHECK(!ObserverSlotVisited(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guard is on the DESIGN'S OWNER: an NPC's designs are invisible to everyone, unless the
|
||||||
|
// NPC is a rebel AI.
|
||||||
|
static void test_owner_guard() {
|
||||||
|
CHECK(DesignOwnerIsObservable(/*npc*/ false, /*rebel*/ false));
|
||||||
|
CHECK(DesignOwnerIsObservable(false, true));
|
||||||
|
CHECK(!DesignOwnerIsObservable(true, false));
|
||||||
|
CHECK(DesignOwnerIsObservable(true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
test_create();
|
||||||
|
test_refresh_keeps_first_seen();
|
||||||
|
test_reobservation_moves_to_the_back();
|
||||||
|
test_key_is_the_design_id_alone();
|
||||||
|
test_cap_is_twenty_per_owner();
|
||||||
|
test_cap_buckets_by_owner();
|
||||||
|
test_thrash_resets_first_seen();
|
||||||
|
test_slot_limit();
|
||||||
|
test_owner_guard();
|
||||||
|
return simtest::finish("observed");
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue