sots-engine/tests/app/test_treaty.cpp
alex 5d29ee60e4 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.
2026-09-08 14:47:47 -04:00

159 lines
7.5 KiB
C++

// 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;
}