merge lane T2: treaty turn stamps (H02); 157 -> 131 leaves, closed 78, regressed 0

This commit is contained in:
alex 2026-09-08 15:05:24 -04:00
commit eafbc5f03a
8 changed files with 370 additions and 5 deletions

View file

@ -7,6 +7,7 @@ add_library(sots_app STATIC
alliance.cpp alliance.cpp
phase_catalog.cpp phase_catalog.cpp
trade_raid.cpp trade_raid.cpp
treaty.cpp
turn_record.cpp turn_record.cpp
construction_phase.cpp construction_phase.cpp
visibility_phase.cpp visibility_phase.cpp

View file

@ -44,7 +44,13 @@ constexpr PhaseDesc kHost[] = {
{Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented, {Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented,
"advances the frame counter, which is the turn number the whole game displays; runs " "advances the frame counter, which is the turn number the whole game displays; runs "
"before the spine and is where the 'Begin processing turn N' line comes from"}, "before the spine and is where the 'Begin processing turn N' line comes from"},
{Driver::Host, 1, "H01", "SaveWriterInvariants", PhaseStatus::Implemented, {Driver::Host, 1, "H02", "StampTreatyTurns", PhaseStatus::Implemented,
"the diplomacy ledger's 'this treaty was last in force on turn N' stamp, over every "
"ORDERED pair of players that holds one, creating the entry on demand. Runs in the "
"command-application step -- after the frame counter has advanced and before either "
"turn driver -- so it stamps the NEW turn. It is the only writer of these fields on a "
"turn with no combat and no diplomatic command"},
{Driver::Host, 2, "H01", "SaveWriterInvariants", PhaseStatus::Implemented,
"the summary's turn number is the simulation's frame counter -- an identity that holds " "the summary's turn number is the simulation's frame counter -- an identity that holds "
"across every save in the corpus and belongs to the writer, not to a turn phase"}, "across every save in the corpus and belongs to the writer, not to a turn phase"},
}; };

89
src/app/treaty.cpp Normal file
View file

@ -0,0 +1,89 @@
#include "app/treaty.h"
namespace sots::app {
using mars::stream::shapes::DipStat;
using mars::stream::shapes::Player;
using mars::stream::shapes::PlayerEntry;
namespace {
// "This kind of treaty was last in force on turn N", one field per treaty kind. A fresh
// entry starts all three at -1, which is what distinguishes "never" from "on turn 0".
constexpr std::int32_t kNever = -1;
std::int32_t* StampField(DipStat& e, Relation r) {
switch (r) {
case Relation::Allied: return &e.lastally;
case Relation::NonAggression: return &e.lastnap;
case Relation::CeaseFire: return &e.lastcf;
case Relation::War: return nullptr;
}
return nullptr;
}
} // namespace
Relation RelationTo(const Player& self, const Player& other) {
// Self first, and by the index field -- two players with the same index are the same
// player as far as this test is concerned, which is how the original short-circuits.
if (self.plyrIdx == other.plyrIdx) return Relation::Allied;
// x86 masks a variable shift count to five bits. Reproduced rather than corrected, for
// the same reason `alliance.cpp` reproduces it: the point is to be the original.
const std::uint32_t bit = 1u << (static_cast<unsigned>(other.plyrIdx) & 31u);
// The alliance mask is tested UNCONDITIONALLY here -- there is no alliance-id guard, and
// that is the difference from the shared-vision mask in alliance.cpp.
if (static_cast<std::uint32_t>(self.alliances.al) & bit) return Relation::Allied;
if (static_cast<std::uint32_t>(self.alliances.na) & bit) return Relation::NonAggression;
if (static_cast<std::uint32_t>(self.alliances.cf) & bit) return Relation::CeaseFire;
return Relation::War;
}
DipStat NewDipStat(std::int32_t otherPlayerId) {
DipStat e;
e.other = otherPlayerId;
e.lastnap = kNever;
e.lastally = kNever;
e.lastcf = kNever;
return e;
}
TreatyStampResult StampTreatyTurns(std::vector<PlayerEntry>& players, std::int32_t frame) {
TreatyStampResult r;
const std::size_t n = players.size();
for (std::size_t i = 0; i < n; ++i) {
Player& a = players[i].player;
for (std::size_t j = 0; j < n; ++j) {
if (i == j) continue; // the original compares the POINTERS, not the indices
const Player& b = players[j].player;
const Relation rel = RelationTo(a, b);
if (rel == Relation::War) continue;
++r.pairsStamped;
const std::int32_t otherId = players[j].playerID;
DipStat* e = nullptr;
for (auto& cand : a.dipstats) // linear, first match, exactly as the original
if (cand.other == otherId) {
e = &cand;
break;
}
if (!e) {
a.dipstats.push_back(NewDipStat(otherId)); // appended at the END
e = &a.dipstats.back();
++r.entriesCreated;
}
std::int32_t* field = StampField(*e, rel);
if (field && *field != frame) ++r.fieldsWritten;
if (field) *field = frame;
}
}
// The phase's own accounting: one for each stamp that moved a value, plus one for each
// record that did not exist before. It is deliberately NOT an attempt to predict the
// divergence report's leaf count, which also charges two container leaves to each player
// whose ledger goes from empty to non-empty -- on the reference pair this reports 28
// where the report closes 26. The report is the authority; this is what the phase did.
r.leafWrites = r.fieldsWritten + r.entriesCreated;
return r;
}
} // namespace sots::app

76
src/app/treaty.h Normal file
View file

@ -0,0 +1,76 @@
// The treaty-turn stamp -- a host step, not a phase of either turn driver.
//
// Once per turn, after the turn's commands have been applied and before either turn driver
// runs, the server walks every ORDERED pair of players and, for each pair that currently
// holds a treaty, stamps the current turn number into that player's diplomacy-stats entry for
// the other. The entry is created on demand. Nothing else in a normal turn writes those
// fields, so the whole per-player `dipstats` vector of a save is this step's output.
//
// It is the diplomacy ledger the AI reads. There is no attitude score in the simulation: what
// a player knows about another's diplomatic history is thirteen counters, and three of them
// are "the turn the treaty of this kind was last in force".
//
// Four things about it are worth stating here because each is a place a reimplementation
// goes quietly wrong.
//
// 1. The stamped value is the TURN, not the modification counter. The two are easy to
// confuse -- they are adjacent words on the same object and an earlier reading of this
// step named the wrong one -- but they differ by an order of magnitude in every save: on
// the corpus's turn-23 save the counter is in the hundreds and the stamp is 23.
// 2. The relation codes are 3 = allied (and self), 2 = non-aggression, 1 = cease-fire,
// 0 = war. An earlier reading had 1 and 3 the other way round, which puts the stamp in
// the cease-fire field of an allied pair.
// 3. The relation's bit is the player's own INDEX FIELD, not its position in the player
// vector. This is the exact opposite of the shared-vision mask in `alliance.h`, which
// uses the vector position and never loads the index field. Both live in the same
// subsystem and they disagree. No save in the corpus separates them.
// 4. The alliance mask is consulted unconditionally. Unlike the shared-vision mask, it is
// NOT gated on the player carrying an alliance id.
//
// Existing entries are found by a linear first-match scan on the other player's id, and a new
// entry is appended at the END -- so the vector's order is the order in which pairs were
// first stamped, which is player-vector order.
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
#include "mars/stream/shapes.h"
namespace sots::app {
// What one player currently is to another. The values are the original's, and they are the
// values the rest of the game tests against (`== 3` gates allied resupply; `< 1` is war).
enum class Relation : int {
War = 0,
CeaseFire = 1,
NonAggression = 2,
Allied = 3, // and self
};
// `self` and `other` are read from the wire's player records. `other`'s INDEX FIELD supplies
// the bit; a player is always `Allied` to itself.
Relation RelationTo(const mars::stream::shapes::Player& self,
const mars::stream::shapes::Player& other);
// The value a fresh diplomacy-stats entry starts at: the three "last in force" fields at -1
// and every counter at 0. Held as a function rather than as a default member initialiser
// because the wire shape's own default is all-zero, and -1 is this step's, not the wire's.
mars::stream::shapes::DipStat NewDipStat(std::int32_t otherPlayerId);
// What the step did, so the phase log can report it without the phase re-deriving it.
struct TreatyStampResult {
int pairsStamped = 0; // ordered pairs that held a treaty
int entriesCreated = 0; // entries that did not exist before this turn
int fieldsWritten = 0; // stamps applied; equals pairsStamped
int leafWrites = 0; // save leaves this moved, counted the way the report counts them
};
// The whole step, in place. `frame` is the turn number AFTER the host has advanced it --
// this runs downstream of that increment, so a caller that has not advanced the frame yet
// will stamp every entry one turn short.
TreatyStampResult StampTreatyTurns(std::vector<mars::stream::shapes::PlayerEntry>& players,
std::int32_t frame);
} // namespace sots::app

View file

@ -10,6 +10,7 @@
#include "app/alliance.h" #include "app/alliance.h"
#include "app/construction_phase.h" #include "app/construction_phase.h"
#include "app/trade_raid.h" #include "app/trade_raid.h"
#include "app/treaty.h"
#include "app/turn_record.h" #include "app/turn_record.h"
#include "app/visibility_phase.h" #include "app/visibility_phase.h"
#include "game/sim/colony.h" #include "game/sim/colony.h"
@ -860,6 +861,30 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
r.records.push_back(rec); r.records.push_back(rec);
} }
// H02 StampTreatyTurns: the diplomacy ledger's "last in force on turn N" stamp. It runs
// in the command-application step, which is AFTER the frame counter above and before
// either turn driver -- the ordering is load-bearing, because the value stamped is the
// new turn and a step placed before H00 would stamp every entry one turn short.
{
PhaseRecord rec;
rec.desc = &hp[1];
const TreatyStampResult ts = StampTreatyTurns(game.sim.players, game.sim.frame);
rec.invocations = ts.pairsStamped;
rec.leafWrites = ts.leafWrites;
rec.committed = true;
rec.notes.push_back(fmt("%d ordered pair(s) hold a treaty; %d entry(s) created, "
"%d stamp(s) written at turn %d",
ts.pairsStamped, ts.entriesCreated, ts.fieldsWritten,
game.sim.frame));
if (ts.pairsStamped == 0)
rec.notes.push_back("no player in this save holds any treaty, so this run "
"exercises the relation test only and writes nothing");
rec.notes.push_back("the betrayal half of the same step needs the turn's diplomatic "
"commands (the alliance/NAP/cease-fire broken masks); with no "
"command stream it is provably a no-op and is not modelled");
r.records.push_back(rec);
}
PlayerPhaseTotals pt; PlayerPhaseTotals pt;
SystemTotals st; SystemTotals st;
bool playerDriverRan = false; bool playerDriverRan = false;
@ -1100,7 +1125,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
// H01 SaveWriterInvariants. // H01 SaveWriterInvariants.
{ {
PhaseRecord rec; PhaseRecord rec;
rec.desc = &hp[1]; rec.desc = &hp[2];
const int before = game.summary.turn; const int before = game.summary.turn;
ApplySaveWriterInvariants(game, r); ApplySaveWriterInvariants(game, r);
rec.invocations = 1; rec.invocations = 1;

View file

@ -22,13 +22,20 @@ add_executable(app_test_alliance test_alliance.cpp)
target_link_libraries(app_test_alliance PRIVATE sots_app) target_link_libraries(app_test_alliance PRIVATE sots_app)
add_test(NAME app_alliance COMMAND app_test_alliance) add_test(NAME app_alliance COMMAND app_test_alliance)
# The treaty-turn stamp, as a rule. Pins the five things the corpus cannot separate -- the
# relation codes, the bit's source, the missing alliance-id guard, the -1 initialiser and the
# append order -- so it always runs.
add_executable(app_test_treaty test_treaty.cpp)
target_link_libraries(app_test_treaty PRIVATE sots_app)
add_test(NAME app_treaty COMMAND app_test_treaty)
# The turn-record model against the record the game itself archived; needs the owner's saves. # The turn-record model against the record the game itself archived; needs the owner's saves.
add_executable(app_test_turn_record test_turn_record.cpp) add_executable(app_test_turn_record test_turn_record.cpp)
target_link_libraries(app_test_turn_record PRIVATE sots_app) target_link_libraries(app_test_turn_record PRIVATE sots_app)
add_test(NAME app_turn_record COMMAND app_test_turn_record) add_test(NAME app_turn_record COMMAND app_test_turn_record)
foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance
app_test_turn_record) app_test_treaty app_test_turn_record)
target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic)
endforeach() endforeach()

View file

@ -48,8 +48,10 @@ int main() {
CHECK(nt == 37); CHECK(nt == 37);
CHECK(static_cast<int>(ns + np) == kSpinePhaseCount); CHECK(static_cast<int>(ns + np) == kSpinePhaseCount);
// The host steps are deliberately NOT part of the milestone's denominator. // The host steps are deliberately NOT part of the milestone's denominator. Their ids run
CHECK(nh == 2); // H00, H02, H01 because the table is in EXECUTION order and H02 was read later than the
// step it runs before; the id is the stable name, the index is the ordinal.
CHECK(nh == 3);
CheckTable(h, nh, Driver::Host, 0, ids, names); CheckTable(h, nh, Driver::Host, 0, ids, names);
CheckTable(s, ns, Driver::Strategic, 0, ids, names); CheckTable(s, ns, Driver::Strategic, 0, ids, names);
CheckTable(p, np, Driver::Player, 1, ids, names); CheckTable(p, np, Driver::Player, 1, ids, names);

159
tests/app/test_treaty.cpp Normal file
View file

@ -0,0 +1,159 @@
// The treaty-turn stamp, pinned as a rule rather than as a table of observations.
//
// The corpus agrees with this rule on ten of eleven saves -- two games, species 0/2/4/5,
// turns 2 through 23 -- reproducing every entry, every `other` id, every entry's ORDER and
// every stamped value. (The eleventh is a turn-1 save on which no turn has been processed,
// so its ledger is empty; it is the input of the reference pair and the rule predicts what
// it becomes.) That comparison lives against the owner's saves. What is pinned HERE is what
// the corpus cannot separate:
//
// * the relation codes. Every save's treaties are either alliances or non-aggression
// pacts, so the cease-fire arm has never been exercised and an implementation that
// swapped 1 and 3 would look right on ten of the eleven if it also swapped the fields.
// * the bit is the player's INDEX FIELD, not its vector position. Every save in the corpus
// has the two equal.
// * the alliance mask is consulted with no alliance-id guard, unlike the shared-vision
// mask -- in the corpus no player holds an alliance mask without an alliance id.
// * a fresh entry's three "last in force" fields start at -1, not 0. A save cannot show
// this for a field that was then stamped; it shows only for the two that were not.
// * the entry is appended at the END and found by first match, so re-running the step is
// idempotent rather than appending duplicates.
#include <cstdio>
#include "app/treaty.h"
#include "mars/stream/shapes.h"
static int failures = 0;
#define CHECK_EQ(a, b) \
do { \
const long long va = (long long)(a), vb = (long long)(b); \
if (va != vb) { \
std::printf("FAIL %s:%d %s == %s (%lld != %lld)\n", __FILE__, \
__LINE__, #a, #b, va, vb); \
++failures; \
} \
} while (0)
using mars::stream::shapes::PlayerEntry;
using sots::app::Relation;
using sots::app::RelationTo;
using sots::app::StampTreatyTurns;
static std::vector<PlayerEntry> MakePlayers(std::size_t n) {
std::vector<PlayerEntry> v(n);
for (std::size_t i = 0; i < n; ++i) {
v[i].playerID = static_cast<std::int32_t>(16 * (i + 1));
v[i].player.plyrIdx = static_cast<std::int32_t>(i);
}
return v;
}
int main() {
// 1. The relation codes, and their precedence. Alliance beats NAP beats cease-fire, and
// a player is allied to itself whatever the masks say.
{
std::vector<PlayerEntry> p = MakePlayers(3);
p[0].player.alliances.al = 1 << 1;
p[0].player.alliances.na = (1 << 1) | (1 << 2);
p[0].player.alliances.cf = (1 << 1) | (1 << 2);
CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::Allied);
CHECK_EQ((int)RelationTo(p[0].player, p[2].player), (int)Relation::NonAggression);
CHECK_EQ((int)RelationTo(p[0].player, p[0].player), (int)Relation::Allied);
CHECK_EQ((int)RelationTo(p[1].player, p[0].player), (int)Relation::War); // not symmetric
p[0].player.alliances.na = 0;
CHECK_EQ((int)RelationTo(p[0].player, p[2].player), (int)Relation::CeaseFire);
p[0].player.alliances.cf = 0;
CHECK_EQ((int)RelationTo(p[0].player, p[2].player), (int)Relation::War);
}
// 2. The bit is the INDEX FIELD. Give the vector's second element index 5 and the mask
// bit that reaches it is bit 5, not bit 1.
{
std::vector<PlayerEntry> p = MakePlayers(2);
p[1].player.plyrIdx = 5;
p[0].player.alliances.na = 1 << 5;
CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::NonAggression);
p[0].player.alliances.na = 1 << 1;
CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::War);
}
// 3. No alliance-id guard: an alliance mask with no alliance id still means allied.
{
std::vector<PlayerEntry> p = MakePlayers(2);
p[0].player.alliances.alid = -1;
p[0].player.alliances.al = 1 << 1;
CHECK_EQ((int)RelationTo(p[0].player, p[1].player), (int)Relation::Allied);
}
// 4. A created entry: the right field stamped, the other two at -1, every counter 0,
// and `other` the OTHER PLAYER'S ID -- not its index.
{
std::vector<PlayerEntry> p = MakePlayers(2);
p[0].player.alliances.na = 1 << 1;
p[1].player.alliances.na = 1 << 0;
const sots::app::TreatyStampResult r = StampTreatyTurns(p, 7);
CHECK_EQ(r.pairsStamped, 2);
CHECK_EQ(r.entriesCreated, 2);
CHECK_EQ(r.fieldsWritten, 2);
CHECK_EQ(p[0].player.dipstats.size(), 1u);
const auto& e = p[0].player.dipstats[0];
CHECK_EQ(e.other, 32);
CHECK_EQ(e.lastnap, 7);
CHECK_EQ(e.lastally, -1);
CHECK_EQ(e.lastcf, -1);
CHECK_EQ(e.lastnapbty, 0);
CHECK_EQ(e.bknnap, 0);
CHECK_EQ(e.btynap, 0);
CHECK_EQ(e.deadhome, 0);
CHECK_EQ(p[1].player.dipstats[0].other, 16);
}
// 5. Re-running does not duplicate: the entry is found by first match on `other` and the
// stamp is overwritten in place.
{
std::vector<PlayerEntry> p = MakePlayers(2);
p[0].player.alliances.na = 1 << 1;
(void)StampTreatyTurns(p, 7);
const sots::app::TreatyStampResult r = StampTreatyTurns(p, 8);
CHECK_EQ(p[0].player.dipstats.size(), 1u);
CHECK_EQ(p[0].player.dipstats[0].lastnap, 8);
CHECK_EQ(r.entriesCreated, 0);
CHECK_EQ(r.fieldsWritten, 1);
// Stamping the same turn twice writes nothing new.
const sots::app::TreatyStampResult again = StampTreatyTurns(p, 8);
CHECK_EQ(again.fieldsWritten, 0);
CHECK_EQ(again.pairsStamped, 1); // the pair still holds a treaty; it just did not move
}
// 6. Entries are appended in the order pairs are first stamped, which is player-vector
// order -- and a later turn that adds a new treaty appends after the existing ones.
{
std::vector<PlayerEntry> p = MakePlayers(4);
p[0].player.alliances.na = (1 << 2) | (1 << 3);
(void)StampTreatyTurns(p, 4);
CHECK_EQ(p[0].player.dipstats.size(), 2u);
CHECK_EQ(p[0].player.dipstats[0].other, 48); // index 2
CHECK_EQ(p[0].player.dipstats[1].other, 64); // index 3
p[0].player.alliances.al = 1 << 1;
(void)StampTreatyTurns(p, 5);
CHECK_EQ(p[0].player.dipstats.size(), 3u);
CHECK_EQ(p[0].player.dipstats[2].other, 32); // appended at the END, not sorted in
CHECK_EQ(p[0].player.dipstats[2].lastally, 5);
CHECK_EQ(p[0].player.dipstats[2].lastnap, -1);
CHECK_EQ(p[0].player.dipstats[0].lastnap, 5); // and the older ones were re-stamped
}
// 7. War with everyone writes nothing at all -- the ledger of a player with no treaty
// stays empty, which is what both real empires in the reference save look like.
{
std::vector<PlayerEntry> p = MakePlayers(4);
const sots::app::TreatyStampResult r = StampTreatyTurns(p, 9);
CHECK_EQ(r.pairsStamped, 0);
CHECK_EQ(r.leafWrites, 0);
for (const auto& e : p) CHECK_EQ(e.player.dipstats.size(), 0u);
}
std::printf("app_test_treaty: %d failure(s)\n", failures);
return failures ? 1 : 0;
}