merge lane A2: alliance mask rule; ModCount writers enumerated
This commit is contained in:
commit
559d3e22a8
12 changed files with 492 additions and 22 deletions
175
docs/A2-alliance-and-modcount.md
Normal file
175
docs/A2-alliance-and-modcount.md
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
# Lane A2 — the alliance mask (`S04`) and `ModCount`
|
||||||
|
|
||||||
|
Lane A2, 2026-09-08. Branch `wip/alliance`, off `main` `b2bad30` (lane N's output term already
|
||||||
|
merged). Host + static only; lane A2 holds no VM.
|
||||||
|
|
||||||
|
**This section is the prediction, written before the implementation, per earned rule 2.**
|
||||||
|
Everything below the horizontal rule at §3 is the result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The prediction — `S04`, the alliance / shared-vision mask
|
||||||
|
|
||||||
|
The spine's fourth phase is a per-player pre-pass that rebuilds one word of each player's turn
|
||||||
|
record from scratch, every turn, before anything else in the turn can read it. Read from the
|
||||||
|
instruction stream (not the decompiler), the phase is:
|
||||||
|
|
||||||
|
```
|
||||||
|
for i in 0 .. numPlayers-1: # i is the player's POSITION IN THE SERVER VECTOR
|
||||||
|
p = players[i]
|
||||||
|
rec = p.turnRecord
|
||||||
|
rec.allianceMask = 0
|
||||||
|
rec.allianceMask |= (1 << i)
|
||||||
|
if p.allianceId != -1:
|
||||||
|
rec.allianceMask |= p.allianceMembers
|
||||||
|
```
|
||||||
|
|
||||||
|
so, as one expression,
|
||||||
|
|
||||||
|
```
|
||||||
|
almem[i] = (1u << i) | (alid[i] != -1 ? al[i] : 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both inputs are on the wire: they are the two leading ints of the player's second `Team` item
|
||||||
|
(`ALid`, `AL`). The output is on the wire too — it is the `almem` field of the player's
|
||||||
|
turn-statistics element for the turn.
|
||||||
|
|
||||||
|
**The prediction, in the form the test will check it:** over the whole eleven-save corpus, for
|
||||||
|
every player of every save, for the archive element whose `trn` equals the save's own frame,
|
||||||
|
|
||||||
|
> `almem == (1 << playerVectorIndex) | (ALid != -1 ? AL : 0)`
|
||||||
|
|
||||||
|
with **zero** mismatches, and with `playerVectorIndex` being the player's ordinal position in
|
||||||
|
`/Sim/players` — **not** its `PlyrIdx`.
|
||||||
|
|
||||||
|
Committing this closes the 8 `almem` leaves that lane Y measured as regressing `T36`, and moves
|
||||||
|
`T36` from "right in six fields, wrong in five" to "right in seven fields, wrong in four".
|
||||||
|
|
||||||
|
### 1.1 Why the bit index is the vector position and not `PlyrIdx`
|
||||||
|
|
||||||
|
The shift count is the loop induction variable, which is the index used to subscript the player
|
||||||
|
vector on the same iteration. Nothing loads `PlyrIdx` anywhere in the phase. Lane T recorded this
|
||||||
|
correctly and it is restated here because it is the single most likely place for a
|
||||||
|
reimplementation to be quietly wrong: every *other* per-player index in the tail (the turn-results
|
||||||
|
array, the battle tally, the archive key) uses `PlyrIdx`, and this one does not.
|
||||||
|
|
||||||
|
### 1.2 What this phase does NOT do
|
||||||
|
|
||||||
|
It does not read, and does not write, `NA` or `CF` — the other two ints of the same `Team` item.
|
||||||
|
It does not consult the game's alliance-enabled flag (`EnAl`). It writes exactly one word.
|
||||||
|
|
||||||
|
## 2. Falsification
|
||||||
|
|
||||||
|
Each row is a way the model above can be wrong, and the symptom that would show it.
|
||||||
|
|
||||||
|
| # | how the model could be wrong | symptom |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | the bit is `1 << PlyrIdx`, not `1 << vectorIndex` | on any save where the two orders differ, `almem`'s low bit pattern is off by a permutation. **Zero mismatches on a save where they agree proves nothing** — the check must report how many saves actually separate the two |
|
||||||
|
| F2 | the guard is on something other than `ALid != -1` (e.g. on `AL != 0`, or on the game's `EnAl`) | indistinguishable while no player is in an alliance. Only a save with a live alliance can separate them |
|
||||||
|
| F3 | `AL` is a mask over `PlyrIdx` bits while the self-bit is a vector-position bit | `almem` would mix two index spaces. Same blind spot as F2: unobservable without an alliance |
|
||||||
|
| F4 | phase 36 (`FinalizeTurnRecords`) overwrites `almem` after phase 4 sets it | mismatches everywhere, and the stored value would correlate with something else entirely |
|
||||||
|
| F5 | the archived element for the save's own frame was written by the **load** path rather than by the tail (`FinalizeTurnRecords` runs in both) | the self-bit would be missing, because the load path does not run spine phase 4. Expect `almem == 0`, or a value that is stale by one turn |
|
||||||
|
| F6 | the shift wraps | `shl` masks its count to 5 bits, so a 32nd player would set bit 0. No save has more than 8 players; the model is untested above 31 and says so |
|
||||||
|
| F7 | the corpus never exercises the alliance term at all | then this lane has closed the **self-bit** and *nothing else*, and must say so under rule 6 rather than claim the rule |
|
||||||
|
|
||||||
|
F7 is the one I expect to bite. The prior is that no save in the corpus has a player in an
|
||||||
|
alliance, in which case the measured agreement tests only `1 << i`, and the `|= AL` arm is a
|
||||||
|
**hypothesis** read from the instruction stream with no exercised evidence. That is a real result
|
||||||
|
and it is a smaller one than "the alliance mask is closed", and it will be reported as the smaller
|
||||||
|
one.
|
||||||
|
|
||||||
|
## 3. The prediction — `ModCount`
|
||||||
|
|
||||||
|
`ModCount` is a **modification counter**, not a turn number and not a phase counter, and the count
|
||||||
|
a turn adds is a function of the *command stream*, not of the pre-turn state.
|
||||||
|
|
||||||
|
**The prediction:**
|
||||||
|
|
||||||
|
1. Every writer is an **entry-block increment inside a `StrategySim` command-application method** —
|
||||||
|
the `OnCommand_*` family. The bump is unconditional and happens *before* the handler validates
|
||||||
|
its arguments, so a command that fails validation still advances the counter.
|
||||||
|
2. The two turn drivers each add exactly 1.
|
||||||
|
3. `ProcessTurn` phase 1 adds 1 per system that passes its gate; the gate is closed on all eleven
|
||||||
|
saves, so it contributes 0 to every measured turn.
|
||||||
|
4. Therefore **`ModCount` is not derivable from the pre-turn save**, and `S00`/`T00`'s two bumps
|
||||||
|
are the only part of it this standalone can ever produce without modelling the AI's order
|
||||||
|
stream. The remaining 10–42 bumps a turn are commands.
|
||||||
|
|
||||||
|
**Falsification for §3:** if the delta were a function of the pre-turn state, then two saves with
|
||||||
|
the same state shape would show the same delta. `turn1→turn2` and `turn2→turn3` both show exactly
|
||||||
|
12, which is *consistent* with a state function and is the strongest evidence against my reading;
|
||||||
|
a third pair from the same game showing a different delta with an unchanged board would settle it.
|
||||||
|
The counter-evidence I already hold is that `human-turn2→turn3` is 28 and `zuul-turn16→turn17` is
|
||||||
|
16 on boards whose system and player counts are identical (28 systems, 7 players) — so it is not a
|
||||||
|
function of the board size. If someone finds a closed-form rule over the pre-turn save that fits
|
||||||
|
all eleven saves, this section is wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Result
|
||||||
|
|
||||||
|
Everything above this line was committed (`49ae628`) before a line of the implementation existed.
|
||||||
|
|
||||||
|
## 4.1 The divergence delta — `closed` and `regressed`, never netted
|
||||||
|
|
||||||
|
`tools/standalone_report.py`, both real End-Turn pairs, on `wip/alliance`:
|
||||||
|
|
||||||
|
| pair | run | baseline | after | **closed** | **regressed** |
|
||||||
|
|---|---|---:|---:|---:|---:|
|
||||||
|
| `turn1 → turn2` | default | 209 | **204** | **5** | **0** |
|
||||||
|
| `turn1 → turn2` | `--commit-blocked` | 209 | 189 | **29** | **9** *(was 17)* |
|
||||||
|
| `turn2 → turn3` | default | 108 | **103** | **5** | **0** |
|
||||||
|
| `turn2 → turn3` | `--commit-blocked` | 108 | 106 | **13** | **11** *(was 19)* |
|
||||||
|
|
||||||
|
**The default run is unchanged at 5 closed / 0 regressed, and that is the honest headline.** `S04`
|
||||||
|
writes no save leaf of its own: the alliance mask reaches the wire only through `T36`, and `T36`
|
||||||
|
is still blocked.
|
||||||
|
|
||||||
|
What moved is `T36`'s cost of committing. The 8 `almem` regressions are **gone**, and the
|
||||||
|
regression set on the reference pair is now exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
inc x3 sav x3 <- the budget: blocked behind the per-system money output
|
||||||
|
shpt[0] shpt[2] satt[0] <- the ship census: needs each design's hull size and 0x400 class flag
|
||||||
|
```
|
||||||
|
|
||||||
|
Nine leaves, from two named blockers, both owned by other lanes in flight. On the reference pair
|
||||||
|
`T36` now closes **all 24** `/Sim/turnstats` leaves. **When the budget and the design catalogue
|
||||||
|
land, `T36` is a clean +24 with nothing left over** — the same conclusion lane Y reached, with the
|
||||||
|
alliance third of it now paid for.
|
||||||
|
|
||||||
|
One thing lane Y wrote is no longer true and is corrected here: *"which way the net falls depends
|
||||||
|
on the save"*. It does not any more — `--commit-blocked` is net positive on **both** pairs (+20 and
|
||||||
|
+2, against +12 and −9 before). `T36` nevertheless **stays blocked**, because `regressed > 0` means
|
||||||
|
nine leaves would be confidently wrong, and that is the whole rule the status exists to enforce.
|
||||||
|
|
||||||
|
## 4.2 The alliance rule — measured
|
||||||
|
|
||||||
|
`almem == (1 << vectorIndex) | (ALid != -1 ? AL : 0)`, against bytes the game wrote:
|
||||||
|
**11 saves, 80 player-records, 560 fields compared, 0 mismatches** (`app_turn_record`, up from 480
|
||||||
|
fields). 14 records carry a live alliance id, so falsifier **F7** did not bite — the alliance term
|
||||||
|
is exercised.
|
||||||
|
|
||||||
|
**F5 fired, and it was the model, not a miss.** All eight `almem` values of `turn1-state.sav` are
|
||||||
|
zero where the rule says otherwise, because the archiving phase also runs on load and the load path
|
||||||
|
does not run the spine. The engine states that as a prediction (`spineRan`), so all 80 records
|
||||||
|
compare rather than 72.
|
||||||
|
|
||||||
|
**F1, F2 and F3 are all unfalsifiable on this corpus and the run says so every time.** Every player
|
||||||
|
in every save has `PlyrIdx == vectorIndex`; every observed alliance mask already contains its own
|
||||||
|
member's bit; and `AL == 0` exactly when `ALid == -1`. `app_alliance` pins all three with the
|
||||||
|
separating inputs no save provides.
|
||||||
|
|
||||||
|
## 4.3 `ModCount` — the prediction stands, and the mechanism is named
|
||||||
|
|
||||||
|
§3's reading is confirmed by the instruction stream and sharpened: the counter is
|
||||||
|
`Game::StrategySim + 0x4`, and the per-turn delta is **2 + one per command applied out of every
|
||||||
|
player's `TurnCommands` block**. The flush is `StrategyServer::ApplyAllTurnCommands`, called from
|
||||||
|
the same message handler that calls both turn drivers, and it walks a `vector<TurnCommands>` whose
|
||||||
|
0x1b4 stride independently confirms the block layout the campaign already recovered.
|
||||||
|
|
||||||
|
So `S00` + `T00`'s two bumps really are all the standalone can produce, and the remaining 10–42 are
|
||||||
|
downstream of the AI's order generation. `ModCount` is **not** the next leaf to chase. The
|
||||||
|
watchpoint that would settle the residual in one turn is specified in
|
||||||
|
`sots-re/findings/control-flow/alliance-mask-and-modcount.md` §3, with its own written prediction
|
||||||
|
(exactly 12 hits on the reference workload) and four falsifiers.
|
||||||
|
|
@ -78,6 +78,11 @@ Nothing is `verified`. That is deliberate: in this table `verified` means "compa
|
||||||
the live game", and lane S2 holds no VM. `app_test_catalog` asserts `verified == 0` so the
|
the live game", and lane S2 holds no VM. `app_test_catalog` asserts `verified == 0` so the
|
||||||
claim cannot drift upward by accident.
|
claim cannot drift upward by accident.
|
||||||
|
|
||||||
|
> **Updated by lane A2, 2026-09-08.** `S04 RebuildAllianceMasks` is now `implemented`
|
||||||
|
> (15 of 44 modelled, 9 committed), and `T36`'s modelled field count went from six to seven.
|
||||||
|
> The tables below are lane S2's originals and are one lane behind; `docs/A2-alliance-and-modcount.md`
|
||||||
|
> §4 carries the current numbers and the measured `--commit-blocked` delta.
|
||||||
|
|
||||||
### 3.1 Current state
|
### 3.1 Current state
|
||||||
|
|
||||||
| driver | phases | modelled | committed |
|
| driver | phases | modelled | committed |
|
||||||
|
|
@ -103,6 +108,7 @@ claim cannot drift upward by accident.
|
||||||
| `P09 AccumulateTimedResearchBonuses` | the timed research-bonus vector, iterated **last → first**, which is load-bearing because float addition is not associative |
|
| `P09 AccumulateTimedResearchBonuses` | the timed research-bonus vector, iterated **last → first**, which is load-bearing because float addition is not associative |
|
||||||
| `P10 ConsumeResearchRollPending` | the strict `0.5f < progress/cost` test and the in-branch flag clear |
|
| `P10 ConsumeResearchRollPending` | the strict `0.5f < progress/cost` test and the in-branch flag clear |
|
||||||
| `T00 IncrementModCount` | the tail's own bump of the same counter |
|
| `T00 IncrementModCount` | the tail's own bump of the same counter |
|
||||||
|
| `S04 RebuildAllianceMasks` | the per-player shared-vision mask (lane A2); writes no leaf of its own — it reaches the wire through `T36` |
|
||||||
|
|
||||||
**Evaluated and reported, not committed:** `P01` `P02` `P03` `P05` `P06` `P11` `S31` `T31`.
|
**Evaluated and reported, not committed:** `P01` `P02` `P03` `P05` `P06` `P11` `S31` `T31`.
|
||||||
|
|
||||||
|
|
@ -186,6 +192,12 @@ In the order they must be solved, not in order of size.
|
||||||
3. **The post-combat tail.** 37 phases, none implemented, and it is the driver the autosave is
|
3. **The post-combat tail.** 37 phases, none implemented, and it is the driver the autosave is
|
||||||
written from. `turnstats`, the bankruptcy limits, the observed-design records and the
|
written from. `turnstats`, the bankruptcy limits, the observed-design records and the
|
||||||
player reports all live there.
|
player reports all live there.
|
||||||
|
3a. **`ModCount`.** Named and enumerated by lane A2: it is a *command* counter, bumped once on
|
||||||
|
entry to each of 26 `StrategySim` command-application sites plus once by each turn driver. Its
|
||||||
|
per-turn delta is the number of commands applied out of every player's queued command block,
|
||||||
|
which is downstream of the AI's order generation. `S00` + `T00` are all this standalone can
|
||||||
|
produce, and the leaf cannot close before the AI does. See `docs/A2-alliance-and-modcount.md`.
|
||||||
|
|
||||||
4. **`Summary.Checksum`.** Its algorithm is unknown. It is one leaf, and it is the *last* leaf:
|
4. **`Summary.Checksum`.** Its algorithm is unknown. It is one leaf, and it is the *last* leaf:
|
||||||
whatever it hashes, it cannot be right until everything it hashes is right.
|
whatever it hashes, it cannot be right until everything it hashes is right.
|
||||||
5. **The `Player.Status` writer.** The phase writes 1, the file carries 4, a load resets to 0.
|
5. **The `Player.Status` writer.** The phase writes 1, the file carries 4, a load resets to 0.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
# The turn logic is a library so the test can drive it in process; `sots_turn` is the CLI.
|
# The turn logic is a library so the test can drive it in process; `sots_turn` is the CLI.
|
||||||
|
|
||||||
add_library(sots_app STATIC
|
add_library(sots_app STATIC
|
||||||
|
alliance.cpp
|
||||||
phase_catalog.cpp
|
phase_catalog.cpp
|
||||||
trade_raid.cpp
|
trade_raid.cpp
|
||||||
turn_record.cpp
|
turn_record.cpp
|
||||||
|
|
|
||||||
25
src/app/alliance.cpp
Normal file
25
src/app/alliance.cpp
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#include "app/alliance.h"
|
||||||
|
|
||||||
|
namespace sots::app {
|
||||||
|
|
||||||
|
std::int32_t AllianceMask(const mars::stream::shapes::Player& p, std::size_t vectorIndex) {
|
||||||
|
// The original clears the word, ORs in the self bit, and then -- only when the player
|
||||||
|
// carries an alliance id -- ORs in the alliance's own member mask. It is three separate
|
||||||
|
// stores to the same word, and the middle one is what makes the result an OR rather than
|
||||||
|
// an assignment.
|
||||||
|
const std::uint32_t self = 1u << (static_cast<unsigned>(vectorIndex) & 31u);
|
||||||
|
std::uint32_t mask = self;
|
||||||
|
if (p.alliances.alid != kNoAlliance) mask |= static_cast<std::uint32_t>(p.alliances.al);
|
||||||
|
return static_cast<std::int32_t>(mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::int32_t> RebuildAllianceMasks(
|
||||||
|
const std::vector<mars::stream::shapes::PlayerEntry>& players) {
|
||||||
|
std::vector<std::int32_t> out;
|
||||||
|
out.reserve(players.size());
|
||||||
|
for (std::size_t i = 0; i < players.size(); ++i)
|
||||||
|
out.push_back(AllianceMask(players[i].player, i));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace sots::app
|
||||||
47
src/app/alliance.h
Normal file
47
src/app/alliance.h
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// The alliance / shared-vision mask -- the spine's fourth phase.
|
||||||
|
//
|
||||||
|
// Early in every strategic turn, before anything else in the turn can read it, the driver
|
||||||
|
// walks the server's player vector and rebuilds one word on each player's turn record from
|
||||||
|
// scratch. The word is a bitmask of "whose vision this player shares this turn": the player's
|
||||||
|
// own bit, plus every member of the player's alliance when it is in one.
|
||||||
|
//
|
||||||
|
// Two things about it are worth stating in the header because both are places a
|
||||||
|
// reimplementation goes quietly wrong.
|
||||||
|
//
|
||||||
|
// 1. The bit is the player's POSITION IN THE PLAYER VECTOR, not the player's own index
|
||||||
|
// field. Every other per-player index in the tail -- the turn-results array, the battle
|
||||||
|
// tally, the archive key -- uses the index field, and this one does not. The shift count
|
||||||
|
// in the original is the loop induction variable; the index field is never loaded in this
|
||||||
|
// phase.
|
||||||
|
// 2. The mask is rebuilt, not accumulated. The word is cleared first, so nothing survives
|
||||||
|
// from the previous turn.
|
||||||
|
//
|
||||||
|
// The word is not itself a save leaf. It reaches the wire only because the last phase of the
|
||||||
|
// post-combat tail copies the whole turn record into the per-turn statistics archive, which
|
||||||
|
// IS on the wire -- so this phase is checkable against bytes the original wrote.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "mars/stream/shapes.h"
|
||||||
|
|
||||||
|
namespace sots::app {
|
||||||
|
|
||||||
|
// The value the original uses for "this player is in no alliance".
|
||||||
|
constexpr std::int32_t kNoAlliance = -1;
|
||||||
|
|
||||||
|
// One player's mask. `vectorIndex` is the player's position in the server's player vector.
|
||||||
|
//
|
||||||
|
// Above 31 players the original's shift wraps -- x86 masks a variable shift count to five
|
||||||
|
// bits -- so bit (index % 32) would be set. No save in the corpus has more than eight
|
||||||
|
// players and that path is untested; it is reproduced rather than "fixed" because the point
|
||||||
|
// of this file is to be the original, not to be correct.
|
||||||
|
std::int32_t AllianceMask(const mars::stream::shapes::Player& p, std::size_t vectorIndex);
|
||||||
|
|
||||||
|
// The whole phase: every player's mask, in vector order.
|
||||||
|
std::vector<std::int32_t> RebuildAllianceMasks(
|
||||||
|
const std::vector<mars::stream::shapes::PlayerEntry>& players);
|
||||||
|
|
||||||
|
} // namespace sots::app
|
||||||
|
|
@ -58,8 +58,12 @@ constexpr PhaseDesc kStrategic[] = {
|
||||||
{Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub,
|
{Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub,
|
||||||
"ServerTradeManager::ProcessTurn -- feeds the trade slot of the budget"},
|
"ServerTradeManager::ProcessTurn -- feeds the trade slot of the budget"},
|
||||||
{Driver::Strategic, 3, "S03", "RegisterTradeSystems", PhaseStatus::Stub, ""},
|
{Driver::Strategic, 3, "S03", "RegisterTradeSystems", PhaseStatus::Stub, ""},
|
||||||
{Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Stub,
|
{Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Implemented,
|
||||||
"per-player shared-vision / alliance mask, rebuilt from scratch each turn"},
|
"per-player shared-vision / alliance mask, rebuilt from scratch each turn: the player's "
|
||||||
|
"own bit -- its POSITION IN THE PLAYER VECTOR, not its index field -- OR the alliance's "
|
||||||
|
"member mask when the player carries an alliance id. The word is not a leaf of its own; "
|
||||||
|
"it reaches the wire through the tail's turn-record archive, and it agrees with the "
|
||||||
|
"archived bytes on 72 of 80 player-records with the other 8 predicted"},
|
||||||
{Driver::Strategic, 5, "S05", "BuildShipActionTypeSets", PhaseStatus::Stub,
|
{Driver::Strategic, 5, "S05", "BuildShipActionTypeSets", PhaseStatus::Stub,
|
||||||
"builds the two action-type id sets the dispatcher runs over"},
|
"builds the two action-type id sets the dispatcher runs over"},
|
||||||
{Driver::Strategic, 6, "S06", "ShipActionsExceptType2", PhaseStatus::Stub,
|
{Driver::Strategic, 6, "S06", "ShipActionsExceptType2", PhaseStatus::Stub,
|
||||||
|
|
@ -213,12 +217,12 @@ constexpr PhaseDesc kTail[] = {
|
||||||
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
||||||
{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. Six of its "
|
"fills every player's turn record and archives it by turn; must stay last. Seven of its "
|
||||||
"fields are recoverable from the wire and are reproduced -- turn, colony count, savings, "
|
"fields are recoverable from the wire and are reproduced -- turn, colony count, savings, "
|
||||||
"the savings delta, the completed-tech count and the summed population -- and the model is "
|
"the savings delta, the completed-tech count, the summed population and the alliance mask "
|
||||||
"self-checked every run against the record the input save already carries for its own "
|
"phase S04 rebuilt -- and the model is self-checked every run against the record the input "
|
||||||
"turn. Not committed: five further fields of the same record are unmodelled, and savings "
|
"save already carries for its own turn. Not committed: four further fields of the same "
|
||||||
"for the NEW turn comes from a blocked phase"},
|
"record are unmodelled, and savings for the NEW turn comes from a blocked phase"},
|
||||||
};
|
};
|
||||||
|
|
||||||
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {
|
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "app/alliance.h"
|
||||||
#include "app/trade_raid.h"
|
#include "app/trade_raid.h"
|
||||||
#include "app/turn_record.h"
|
#include "app/turn_record.h"
|
||||||
#include "game/sim/colony.h"
|
#include "game/sim/colony.h"
|
||||||
|
|
@ -284,8 +285,12 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
int dangling = 0;
|
int dangling = 0;
|
||||||
|
// The archiving phase runs both at the end of a turn and on load, and only the
|
||||||
|
// end-of-turn path has a spine behind it. The earliest turn the archive carries is
|
||||||
|
// the one the load path wrote, so its alliance mask is predicted to be zero.
|
||||||
|
const bool spineRan = inputTurn > EarliestArchivedTurn(hist);
|
||||||
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
|
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
|
||||||
inputTurn, &dangling);
|
inputTurn, i, spineRan, &dangling);
|
||||||
a.dangling += dangling;
|
a.dangling += dangling;
|
||||||
const TurnRecordDiff d = CompareTurnRecord(built, *stored);
|
const TurnRecordDiff d = CompareTurnRecord(built, *stored);
|
||||||
++a.playersChecked;
|
++a.playersChecked;
|
||||||
|
|
@ -299,7 +304,8 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec,
|
void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec,
|
||||||
const TurnRecordAudit& audit) {
|
const TurnRecordAudit& audit,
|
||||||
|
const std::vector<std::int32_t>& allianceMasks) {
|
||||||
rec.invocations = static_cast<int>(game.sim.players.size());
|
rec.invocations = static_cast<int>(game.sim.players.size());
|
||||||
// Six fields per player would be written, plus a new archive element per player. Nothing
|
// Six fields per player would be written, plus a new archive element per player. Nothing
|
||||||
// is committed by default: five further fields of the same element are unmodelled, and
|
// is committed by default: five further fields of the same element are unmodelled, and
|
||||||
|
|
@ -316,10 +322,15 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord&
|
||||||
++archived;
|
++archived;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const TurnRecord built =
|
// The record being archived is this turn's, and this turn ran the spine, so the
|
||||||
BuildTurnRecord(game.sim.players[i].player, game.sim.systems, game.sim.frame);
|
// alliance mask is the one phase S04 rebuilt rather than a zero from the load path.
|
||||||
|
const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems,
|
||||||
|
game.sim.frame, i, /*spineRan=*/true);
|
||||||
mars::stream::shapes::PlayerTurnStats s;
|
mars::stream::shapes::PlayerTurnStats s;
|
||||||
s.trn = built.turn;
|
s.trn = built.turn;
|
||||||
|
// The mask the spine's phase 4 rebuilt earlier in this same turn, not a value
|
||||||
|
// recomputed here: the dependency between the two phases is real and is expressed.
|
||||||
|
s.almem = i < allianceMasks.size() ? allianceMasks[i] : built.allianceMask;
|
||||||
s.pop = built.population;
|
s.pop = built.population;
|
||||||
s.col = built.colonies;
|
s.col = built.colonies;
|
||||||
s.sav = built.savings;
|
s.sav = built.savings;
|
||||||
|
|
@ -334,11 +345,11 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord&
|
||||||
}
|
}
|
||||||
hist.stats.push_back(s);
|
hist.stats.push_back(s);
|
||||||
++archived;
|
++archived;
|
||||||
rec.leafWrites += 6;
|
rec.leafWrites += 7;
|
||||||
}
|
}
|
||||||
rec.committed = rec.leafWrites > 0;
|
rec.committed = rec.leafWrites > 0;
|
||||||
if (!opt.commitBlocked) rec.wouldWrite = archived * 6;
|
if (!opt.commitBlocked) rec.wouldWrite = archived * 7;
|
||||||
rec.notes.push_back(fmt("%s %d record(s) for turn %d; 6 modelled field(s) each",
|
rec.notes.push_back(fmt("%s %d record(s) for turn %d; 7 modelled field(s) each",
|
||||||
opt.commitBlocked ? "ARCHIVED" : "would archive", archived,
|
opt.commitBlocked ? "ARCHIVED" : "would archive", archived,
|
||||||
game.sim.frame));
|
game.sim.frame));
|
||||||
if (audit.playersChecked)
|
if (audit.playersChecked)
|
||||||
|
|
@ -436,6 +447,9 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
PlayerPhaseTotals pt;
|
PlayerPhaseTotals pt;
|
||||||
SystemTotals st;
|
SystemTotals st;
|
||||||
bool playerDriverRan = false;
|
bool playerDriverRan = false;
|
||||||
|
// Filled by S04 and consumed by the tail's archiving phase. Empty until S04 runs, which
|
||||||
|
// is what makes the ordering between the two visible rather than assumed.
|
||||||
|
std::vector<std::int32_t> allianceMasks;
|
||||||
|
|
||||||
for (std::size_t i = 0; i < ns; ++i) {
|
for (std::size_t i = 0; i < ns; ++i) {
|
||||||
PhaseRecord rec;
|
PhaseRecord rec;
|
||||||
|
|
@ -453,6 +467,29 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
"modelled, so the leaf will not match yet");
|
"modelled, so the leaf will not match yet");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 4: { // S04 RebuildAllianceMasks
|
||||||
|
// Rebuilt from scratch every turn, before anything in the turn can read it.
|
||||||
|
// The word is not a save leaf of its own: it reaches the wire only through
|
||||||
|
// the tail's archiving phase, so this phase commits nothing here and the
|
||||||
|
// count of leaves it will cause to move is reported by that phase.
|
||||||
|
allianceMasks = RebuildAllianceMasks(game.sim.players);
|
||||||
|
int allied = 0;
|
||||||
|
for (const auto& e : game.sim.players)
|
||||||
|
if (e.player.alliances.alid != kNoAlliance) ++allied;
|
||||||
|
rec.invocations = static_cast<int>(allianceMasks.size());
|
||||||
|
rec.committed = true;
|
||||||
|
rec.notes.push_back(fmt("%d mask(s) rebuilt; %d player(s) carry an alliance id",
|
||||||
|
rec.invocations, allied));
|
||||||
|
rec.notes.push_back("the mask is not a leaf of its own -- it reaches the wire "
|
||||||
|
"only through the turn-record archive, so the leaves it "
|
||||||
|
"moves are counted by the tail's last phase");
|
||||||
|
if (allied == 0)
|
||||||
|
rec.notes.push_back("NO player is in an alliance in this save, so this run "
|
||||||
|
"exercises the self bit only and the alliance term is "
|
||||||
|
"an instruction-stream reading with no evidence behind "
|
||||||
|
"it here");
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 11: { // S11 SystemTurn
|
case 11: { // S11 SystemTurn
|
||||||
for (auto& e : game.sim.systems)
|
for (auto& e : game.sim.systems)
|
||||||
RunSystemTurn(e.sys, static_cast<int>(game.sim.players.size()), st);
|
RunSystemTurn(e.sys, static_cast<int>(game.sim.players.size()), st);
|
||||||
|
|
@ -584,7 +621,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
||||||
rec.committed = true;
|
rec.committed = true;
|
||||||
rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount));
|
rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount));
|
||||||
} else if (tp[i].index == 36) {
|
} else if (tp[i].index == 36) {
|
||||||
RunFinalizeTurnRecords(game, opt, rec, recordAudit);
|
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
|
||||||
}
|
}
|
||||||
r.records.push_back(rec);
|
r.records.push_back(rec);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
#include "app/turn_record.h"
|
#include "app/turn_record.h"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
#include "app/alliance.h"
|
||||||
|
|
||||||
namespace sots::app {
|
namespace sots::app {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr const char* kUnmodelled[] = {
|
constexpr const char* kUnmodelled[] = {
|
||||||
"alliance/vision mask -- rebuilt by the alliance-mask phase of the spine, which is a stub",
|
|
||||||
"trade income -- a budget-derived field, blocked behind the per-system money output",
|
"trade income -- a budget-derived field, blocked behind the per-system money output",
|
||||||
"battles fought -- written by the tail's battle-tally phase, which is a stub",
|
"battles fought -- written by the tail's battle-tally phase, which is a stub",
|
||||||
"systems acquired / lost this turn -- the two counted lists are never non-empty in the "
|
"systems acquired / lost this turn -- the two counted lists are never non-empty in the "
|
||||||
|
|
@ -23,9 +25,12 @@ const char* const* TurnRecord::Unmodelled(std::size_t& count) {
|
||||||
|
|
||||||
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
||||||
const std::vector<mars::stream::shapes::SysEntry>& systems,
|
const std::vector<mars::stream::shapes::SysEntry>& systems,
|
||||||
std::int32_t frame, int* danglingOwnedSystems) {
|
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
|
||||||
|
int* danglingOwnedSystems) {
|
||||||
TurnRecord r;
|
TurnRecord r;
|
||||||
r.turn = frame;
|
r.turn = frame;
|
||||||
|
// Written by the spine's fourth phase, which the load path does not run.
|
||||||
|
r.allianceMask = spineRan ? AllianceMask(p, vectorIndex) : 0;
|
||||||
r.savings = p.sav;
|
r.savings = p.sav;
|
||||||
// The turn's income is the change in savings across the turn, not a budget line: the
|
// The turn's income is the change in savings across the turn, not a budget line: the
|
||||||
// previous-turn savings word is on the wire and is stamped before this turn's savings are
|
// previous-turn savings word is on the wire and is stamped before this turn's savings are
|
||||||
|
|
@ -57,6 +62,13 @@ TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist) {
|
||||||
|
std::int32_t earliest = std::numeric_limits<std::int32_t>::max();
|
||||||
|
for (const auto& s : hist.stats)
|
||||||
|
if (s.trn < earliest) earliest = s.trn;
|
||||||
|
return earliest;
|
||||||
|
}
|
||||||
|
|
||||||
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
|
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
|
||||||
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn) {
|
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn) {
|
||||||
for (const auto& s : hist.stats)
|
for (const auto& s : hist.stats)
|
||||||
|
|
@ -81,6 +93,7 @@ TurnRecordDiff CompareTurnRecord(const TurnRecord& built,
|
||||||
cmp("sav", built.savings, stored.sav);
|
cmp("sav", built.savings, stored.sav);
|
||||||
cmp("inc", built.income, stored.inc);
|
cmp("inc", built.income, stored.inc);
|
||||||
cmp("tch", built.completedTech, stored.tch);
|
cmp("tch", built.completedTech, stored.tch);
|
||||||
|
cmp("almem", built.allianceMask, stored.almem);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ struct TurnRecord {
|
||||||
std::int32_t savings = 0;
|
std::int32_t savings = 0;
|
||||||
std::int32_t income = 0; // savings minus previous-turn savings
|
std::int32_t income = 0; // savings minus previous-turn savings
|
||||||
std::int32_t completedTech = 0; // tech-tree entries in the completed state
|
std::int32_t completedTech = 0; // tech-tree entries in the completed state
|
||||||
|
std::int32_t allianceMask = 0; // the shared-vision mask the spine's phase 4 rebuilds
|
||||||
|
|
||||||
// Fields the archive element also carries that this model does NOT fill, kept as a
|
// Fields the archive element also carries that this model does NOT fill, kept as a
|
||||||
// published list rather than as silence. Each is blocked on something named.
|
// published list rather than as silence. Each is blocked on something named.
|
||||||
|
|
@ -39,13 +40,26 @@ constexpr std::int32_t kTechStateCompleted = 4;
|
||||||
|
|
||||||
// Build one player's record from the simulation state. `systemsById` is the save's system
|
// Build one player's record from the simulation state. `systemsById` is the save's system
|
||||||
// table indexed the way the player's owned-system ids index it.
|
// table indexed the way the player's owned-system ids index it.
|
||||||
|
//
|
||||||
|
// `vectorIndex` is the player's position in the player vector -- the alliance mask's bit
|
||||||
|
// index, and NOT the player's own index field. See app/alliance.h.
|
||||||
|
//
|
||||||
|
// `spineRan` says whether the turn whose record this is actually ran the strategic spine.
|
||||||
|
// The archiving phase runs in two places -- at the end of a turn, and on load -- and the
|
||||||
|
// alliance mask is written only by the spine. So a record archived by the load path carries
|
||||||
|
// a zero mask, and that is a positive prediction of this model, not an exclusion: the
|
||||||
|
// earliest turn every save carries has `almem == 0` on every player, in all eleven saves.
|
||||||
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p,
|
||||||
const std::vector<mars::stream::shapes::SysEntry>& systems,
|
const std::vector<mars::stream::shapes::SysEntry>& systems,
|
||||||
std::int32_t frame,
|
std::int32_t frame, std::size_t vectorIndex, bool spineRan,
|
||||||
// set when an owned-system id is not present in the table, which
|
// set when an owned-system id is not present in the table, which
|
||||||
// would silently drop a term from the population sum
|
// would silently drop a term from the population sum
|
||||||
int* danglingOwnedSystems = nullptr);
|
int* danglingOwnedSystems = nullptr);
|
||||||
|
|
||||||
|
// The earliest turn the archive carries for a player. The record for that turn was written
|
||||||
|
// by the new-game / load path, not by a turn.
|
||||||
|
std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist);
|
||||||
|
|
||||||
// What the archive element for `turn` holds, if the save carries one for that turn.
|
// What the archive element for `turn` holds, if the save carries one for that turn.
|
||||||
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
|
const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord(
|
||||||
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn);
|
const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn);
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,19 @@ add_executable(app_test_trade_raid test_trade_raid.cpp)
|
||||||
target_link_libraries(app_test_trade_raid PRIVATE sots_app)
|
target_link_libraries(app_test_trade_raid PRIVATE sots_app)
|
||||||
add_test(NAME app_trade_raid COMMAND app_test_trade_raid)
|
add_test(NAME app_trade_raid COMMAND app_test_trade_raid)
|
||||||
|
|
||||||
|
# The alliance mask, as a rule. Pins the three things the corpus cannot separate -- the bit
|
||||||
|
# index, the OR, and the guard -- so it always runs.
|
||||||
|
add_executable(app_test_alliance test_alliance.cpp)
|
||||||
|
target_link_libraries(app_test_alliance PRIVATE sots_app)
|
||||||
|
add_test(NAME app_alliance COMMAND app_test_alliance)
|
||||||
|
|
||||||
# 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_turn_record)
|
foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance
|
||||||
|
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()
|
||||||
|
|
|
||||||
110
tests/app/test_alliance.cpp
Normal file
110
tests/app/test_alliance.cpp
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// The alliance / shared-vision mask, pinned as a rule rather than as a table of observations.
|
||||||
|
//
|
||||||
|
// The corpus agrees with this rule on 72 of 80 player-records and predicts the other 8 (the
|
||||||
|
// zero masks of every save's earliest archived turn, which the load path wrote without a
|
||||||
|
// spine). That comparison lives in app_test_turn_record, where the stored bytes are. What is
|
||||||
|
// pinned HERE is the part the corpus cannot separate:
|
||||||
|
//
|
||||||
|
// * the bit is the player's position in the vector, not its own index field. Every save in
|
||||||
|
// the corpus has the two equal, so no comparison against stored bytes can tell them
|
||||||
|
// apart -- only the instruction stream can, and this test holds the reading.
|
||||||
|
// * the mask is an OR of the self bit with the alliance's member mask. In the corpus every
|
||||||
|
// observed alliance mask already contains its member's own bit, so `self | AL` and `AL`
|
||||||
|
// are indistinguishable there.
|
||||||
|
// * the guard is on the alliance id, not on the member mask being non-zero. In the corpus
|
||||||
|
// the two always agree, because a player with no alliance id also has a zero mask.
|
||||||
|
//
|
||||||
|
// Three cases that no save exercises, each held here so that a later save which does
|
||||||
|
// exercise one has something to disagree with.
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "app/alliance.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::Player;
|
||||||
|
using mars::stream::shapes::PlayerEntry;
|
||||||
|
|
||||||
|
static Player MakePlayer(int plyrIdx, int alid, int al) {
|
||||||
|
Player p;
|
||||||
|
p.plyrIdx = plyrIdx;
|
||||||
|
p.alliances.alid = alid;
|
||||||
|
p.alliances.al = al;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// 1. No alliance: the self bit alone, at the VECTOR index.
|
||||||
|
for (std::size_t i = 0; i < 8; ++i) {
|
||||||
|
const Player p = MakePlayer(static_cast<int>(i), sots::app::kNoAlliance, 0);
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, i), 1 << i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The index field disagrees with the vector position. The original shifts by the loop
|
||||||
|
// variable and never loads the index field, so the vector position wins. No save in the
|
||||||
|
// corpus has these two apart; this is the instruction-stream reading, pinned.
|
||||||
|
{
|
||||||
|
const Player p = MakePlayer(/*plyrIdx=*/7, sots::app::kNoAlliance, 0);
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, 2), 1 << 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. In an alliance whose member mask does NOT already contain the player's own bit.
|
||||||
|
// Every alliance in the corpus does contain it, so `self | AL` and `AL` are the same
|
||||||
|
// number there. Here they are not, and the OR is what is being held.
|
||||||
|
{
|
||||||
|
const Player p = MakePlayer(/*plyrIdx=*/1, /*alid=*/0, /*al=*/0x0c); // bits 2 and 3
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, 1), 0x0e); // 1|2|3
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The guard is the alliance id, not the member mask. A stale member mask with no
|
||||||
|
// alliance id contributes nothing. Unobserved in the corpus, where the two never differ.
|
||||||
|
{
|
||||||
|
const Player p = MakePlayer(/*plyrIdx=*/1, sots::app::kNoAlliance, /*al=*/0x0c);
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, 1), 0x02);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. An alliance id of zero is a real alliance. -1 is the only "none", and a check
|
||||||
|
// written as `if (alid)` would drop every player of alliance 0 -- which is the only
|
||||||
|
// alliance id that appears anywhere in the corpus.
|
||||||
|
{
|
||||||
|
const Player p = MakePlayer(/*plyrIdx=*/2, /*alid=*/0, /*al=*/0x0c);
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, 2), 0x0c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. The whole phase, in vector order, and the shape the corpus actually shows: two
|
||||||
|
// allied players carrying the same member mask, the rest alone.
|
||||||
|
{
|
||||||
|
std::vector<PlayerEntry> players(8);
|
||||||
|
for (std::size_t i = 0; i < players.size(); ++i)
|
||||||
|
players[i].player = MakePlayer(static_cast<int>(i), sots::app::kNoAlliance, 0);
|
||||||
|
players[2].player = MakePlayer(2, 0, 0x0c);
|
||||||
|
players[3].player = MakePlayer(3, 0, 0x0c);
|
||||||
|
const std::vector<std::int32_t> m = sots::app::RebuildAllianceMasks(players);
|
||||||
|
CHECK_EQ(m.size(), players.size());
|
||||||
|
const std::int32_t want[8] = {1, 2, 0x0c, 0x0c, 0x10, 0x20, 0x40, 0x80};
|
||||||
|
for (std::size_t i = 0; i < 8; ++i) CHECK_EQ(m[i], want[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. The shift wraps above 31, because x86 masks a variable shift count to five bits.
|
||||||
|
// Untested against any game -- no save has more than eight players -- and reproduced
|
||||||
|
// rather than corrected, so that a reimplementation running a 32-player game diverges the
|
||||||
|
// same way the original does instead of diverging differently.
|
||||||
|
{
|
||||||
|
const Player p = MakePlayer(0, sots::app::kNoAlliance, 0);
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, 32), 1);
|
||||||
|
CHECK_EQ(sots::app::AllianceMask(p, 33), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("app_test_alliance: %d failure(s)\n", failures);
|
||||||
|
return failures ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
//
|
//
|
||||||
// Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this
|
// Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this
|
||||||
// repo.
|
// repo.
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
|
|
||||||
#include <dirent.h>
|
#include <dirent.h>
|
||||||
|
|
||||||
|
#include "app/alliance.h"
|
||||||
#include "app/turn_record.h"
|
#include "app/turn_record.h"
|
||||||
#include "mars/stream/save.h"
|
#include "mars/stream/save.h"
|
||||||
|
|
||||||
|
|
@ -54,6 +56,10 @@ int main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0;
|
int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0;
|
||||||
|
// Reported rather than assumed: how much of the alliance rule the corpus actually
|
||||||
|
// exercises. A green run over records that are all `alid == -1` would test the self bit
|
||||||
|
// and nothing else, and would look identical to a green run that tested everything.
|
||||||
|
int alliedRecords = 0, loadWrittenRecords = 0, indexDiffers = 0;
|
||||||
for (const std::string& path : saves) {
|
for (const std::string& path : saves) {
|
||||||
mars::stream::SaveDocument doc;
|
mars::stream::SaveDocument doc;
|
||||||
try {
|
try {
|
||||||
|
|
@ -78,12 +84,21 @@ int main() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
int dang = 0;
|
int dang = 0;
|
||||||
const sots::app::TurnRecord built =
|
// The archiving phase runs at the end of a turn AND on load, and only the
|
||||||
sots::app::BuildTurnRecord(sim.players[i].player, sim.systems, sim.frame, &dang);
|
// end-of-turn path has a spine behind it to have written the alliance mask. So
|
||||||
|
// the earliest turn the archive carries is predicted to have a zero mask -- a
|
||||||
|
// positive claim the corpus checks on 8 records, not a field skipped.
|
||||||
|
const bool spineRan =
|
||||||
|
sim.frame > sots::app::EarliestArchivedTurn(sim.turnstats.players[i].hist);
|
||||||
|
const sots::app::TurnRecord built = sots::app::BuildTurnRecord(
|
||||||
|
sim.players[i].player, sim.systems, sim.frame, i, spineRan, &dang);
|
||||||
dangling += dang;
|
dangling += dang;
|
||||||
// An owned-system id the save's table does not carry would drop a term from the
|
// An owned-system id the save's table does not carry would drop a term from the
|
||||||
// population sum without any other symptom, so it is a failure, not a note.
|
// population sum without any other symptom, so it is a failure, not a note.
|
||||||
CHECK(dang == 0);
|
CHECK(dang == 0);
|
||||||
|
if (sim.players[i].player.alliances.alid != sots::app::kNoAlliance) ++alliedRecords;
|
||||||
|
if (!spineRan) ++loadWrittenRecords;
|
||||||
|
if (sim.players[i].player.plyrIdx != static_cast<std::int32_t>(i)) ++indexDiffers;
|
||||||
const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored);
|
const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored);
|
||||||
++filePlayers;
|
++filePlayers;
|
||||||
fileFields += diff.compared;
|
fileFields += diff.compared;
|
||||||
|
|
@ -103,11 +118,21 @@ int main() {
|
||||||
// failure mode this campaign has paid for twice.
|
// failure mode this campaign has paid for twice.
|
||||||
CHECK(files > 0);
|
CHECK(files > 0);
|
||||||
CHECK(players > 0);
|
CHECK(players > 0);
|
||||||
CHECK(fields == players * 6);
|
CHECK(fields == players * 7);
|
||||||
|
|
||||||
std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, "
|
std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, "
|
||||||
"%d player(s) with no archive element, %d dangling owned-system id(s), "
|
"%d player(s) with no archive element, %d dangling owned-system id(s), "
|
||||||
"%d failure(s)\n",
|
"%d failure(s)\n",
|
||||||
files, players, fields, noArchive, dangling, failures);
|
files, players, fields, noArchive, dangling, failures);
|
||||||
|
// Coverage of the alliance rule, stated as loudly as the verdict (earned rule 15).
|
||||||
|
std::printf(" alliance mask: %d of %d record(s) carry an alliance id; %d were written by "
|
||||||
|
"the load path and are predicted to be zero; %d record(s) have a vector "
|
||||||
|
"position that differs from the player index field\n",
|
||||||
|
alliedRecords, players, loadWrittenRecords, indexDiffers);
|
||||||
|
if (indexDiffers == 0)
|
||||||
|
std::printf(" NOT SEPARATED by this corpus: every player's vector position equals its "
|
||||||
|
"index field, so no comparison here can tell `1 << position` from "
|
||||||
|
"`1 << index`. The instruction stream is what settles it; app_alliance "
|
||||||
|
"holds that reading.\n");
|
||||||
return failures ? 1 : 0;
|
return failures ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue