lane T2: the treaty-turn stamp; 76 leaves closed across five pairs, 0 regressed

H02 StampTreatyTurns -- 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. It is a host step, not a phase of either turn driver: its sole caller is the
command-application step, which runs after the frame counter has advanced and before
both drivers. On a turn with no combat and no diplomatic command it is the only writer
of these fields, which is why a whole per-player dipstats vector is its output.

Read from the instruction stream; three things an earlier reading had wrong are
corrected in findings/subsystems/treaty-turn-stamp.md: the stamped value is the TURN and
not the modification counter, the relation codes are 3=allied / 2=NAP / 1=cease-fire and
not the reverse, and the bit is the player's index field rather than its position in the
player vector -- the opposite convention from the shared-vision mask two files away.

Measured, closed and regressed reported separately and never netted:

  turn1 -> turn2 (reference)   209 -> 132   closed 77 (was 51), regressed 0
  turn2 -> turn3               108 ->  73   closed 35 (was 21), regressed 0
  human-turn2 -> turn3                      closed 76 (was 64), regressed 0
  zuul-turn15 -> turn16                     closed 30 (was 18), regressed 0
  zuul-turn16 -> turn17                     closed 29 (was 17), regressed 0

The last three are pairs from a different game at turns 2, 15 and 16 that the model was
never fitted to, and it closes exactly the twelve ordered treaty pairs each of them holds.
The rule also reproduces the ledger of ten of the eleven corpus saves entry for entry,
including each entry's order and every stamped value; the eleventh is the turn-1 save
whose ledger no turn has yet written, and it is the reference pair's input.

app_test_treaty pins the five things the corpus cannot separate: the relation codes (no
save exercises cease-fire), the bit's source, the missing alliance-id guard, the -1
initialiser on a fresh entry, and the append-at-the-end order that makes re-running the
step idempotent instead of duplicating rows.

The betrayal half of the same function needs the turn's diplomatic commands; with no
command stream it is provably a no-op and is not modelled.
This commit is contained in:
alex 2026-09-08 14:47:47 -04:00
parent 0f1c007f00
commit 5d29ee60e4
8 changed files with 369 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
visibility_phase.cpp visibility_phase.cpp
turn.cpp turn.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"},
}; };

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

@ -0,0 +1,88 @@
#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;
}
}
// A created entry puts a whole record on the wire, not one value: its own leaf plus the
// element leaf the container gains. A stamp into an entry that already existed moves one
// leaf. Counting it this way keeps the phase's self-report in the same currency as the
// divergence report rather than in a currency of its own.
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

@ -9,6 +9,7 @@
#include "app/alliance.h" #include "app/alliance.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"
@ -722,6 +723,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;
@ -930,7 +955,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;
}