Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4230,9 +4230,12 @@ jobs:
# bc16: the smoke episode this run produced, and the COMMITTED bc16
# fixture -- which is the one that carries ALL THIRTEEN beat kinds
# (tests/test_bc16_beats.nim asserts emission, label and style
# against exactly these bytes). bc16 is the LONGEST recording in
# this repository at 3000 rounds, so it is also the year most
# likely to find a wasm32 address-space failure.
# against exactly these bytes). At 2871 rounds over three games it
# is the longest recording in this repository, so it is also the
# year most likely to find a wasm32 address-space failure. NOTE that
# this smoke steps 200 FRAMES, so its `mismatch_round: -1` covers
# the first 200 rounds only -- the WHOLE-recording re-derivation is
# asserted by tests/test_bc16_replay.nim (r1-F7).
node tools/wasm_replay_smoke.cjs dist/static-replay-viewer \
"${{ github.workspace }}/dist/smoke/replay-bc16.json"
node tools/wasm_replay_smoke.cjs dist/static-replay-viewer \
Expand Down
137 changes: 124 additions & 13 deletions client/replay_broadcast.html
Original file line number Diff line number Diff line change
Expand Up @@ -3704,12 +3704,21 @@
composition and how many rounds away, the outbreak level and multiplier,
dens standing and the tiebreak countdown. It sits IMMEDIATELY ABOVE
var(--band), never inside it, and it KEEPS its wave composition, its
multiplier and its countdown at EVERY width including 360 px. */
multiplier and its countdown at EVERY width including 360 px.
IT WRAPS, and that is what makes the last sentence true (r1-F2). On one
nowrap line the six readouts measure ~570 px, so at 360 px (max-width 92 %
= 331 px) and at 720 px the tail of the strip ran past the box and
`overflow: hidden` ATE IT: `DENS`, the mini-timeline and the countdown were
drawn outside the frame and clipped to nothing, exactly the class of defect
`tools/ci/renderer_fixture.html` exists to catch. `flex-wrap: wrap` keeps
every field on screen at every width instead; `white-space: nowrap` stays,
so a field wraps as a whole and never mid-number. */
#bc16-horde {
position: absolute; left: 50%; transform: translateX(-50%);
bottom: calc(var(--band, 0px) + 148px);
display: flex; gap: 8px; align-items: center;
font: 600 11px/1 var(--mono); color: #cdbfa6;
display: flex; gap: 8px 8px; align-items: center;
flex-wrap: wrap; justify-content: center;
font: 600 11px/1.35 var(--mono); color: #cdbfa6;
background: rgba(10, 12, 18, .82); border: 1px solid #2a3342;
border-radius: 8px; padding: 4px 10px; z-index: 7; white-space: nowrap;
max-width: 92%; overflow: hidden;
Expand All @@ -3721,7 +3730,30 @@
#bc16-horde .togo { color: #8b8375; }
#bc16-horde .tl { letter-spacing: .12em; color: #6f6558; }
#bc16-horde .tl b { color: #f2e8d8; }
#bc16-horde.struck { color: #f2e8d8; border-color: #6a5a3a; }
/* THE THIRD PARTY, NAMED ON SCREEN. The zombie team is drawn and labelled
THE HORDE spectator-side (design note: "the zombie team is drawn and
labelled THE HORDE, is never a seat, never scores"); until r1-F9 the words
appeared only in the agent-facing brief and in a CSS comment. It is the
first field in the strip at EVERY width -- it is the shortest field and the
one that says what the strip is. */
#bc16-horde .horde {
color: #f2e8d8; font-weight: 800; letter-spacing: .1em;
}
/* THE STRIP TAKEOVER. On a wave round or a `turned` event the strip carries
what happened in plain words for two seconds (design note: `WAVE — 34
zombies from 4 dens at ×1.7`, `CLAN ASH'S ARCHON TURNS — a BIGZOMBIE at
34,19`) and `.struck` is the class that dresses it. `.struck` was a rule
nothing ever activated (r1-F9); `renderHorde` adds and removes it now, the
same add/remove shape `renderArchons` uses for the archon pill's `flash`. */
#bc16-horde .tell { color: #ffd9a0; font-weight: 700; }
#bc16-horde.struck {
color: #f2e8d8; border-color: #6a5a3a;
/* While struck the strip carries ONE SENTENCE, so it wraps: the readouts'
`white-space: nowrap` is there to stop a field breaking mid-number, and
applied to a sentence inside an `overflow: hidden` box it clips the
sentence instead (measured at 360 px). */
white-space: normal;
}

#bc16-econ, #bc16-units {
position: absolute; left: 8px;
Expand All @@ -3732,8 +3764,15 @@
}
#bc16-units { bottom: calc(var(--band, 0px) + 76px); }
#bc16-econ { bottom: calc(var(--band, 0px) + 8px); }
/* The two rail boxes WRAP their rows (r1-F2). `width: max-content` capped by
`max-width` bounds the BOX, but a nowrap flex row inside it keeps laying
its fields out past that cap: at 360 px a real econ row measures ~640 px
against a 344 px box, so `rubble +x / -y` was drawn OFF THE FRAME. Wrapping
the row keeps every field on screen at every width; the fields themselves
still never break mid-number. */
#bc16-econ .row, #bc16-units .row {
display: flex; gap: 7px; align-items: baseline; white-space: nowrap;
display: flex; gap: 3px 7px; align-items: baseline; white-space: nowrap;
flex-wrap: wrap;
}
#bc16-econ .who, #bc16-units .who {
min-width: 4.2em; color: #8b8375; font-weight: 700;
Expand Down Expand Up @@ -3789,12 +3828,25 @@
#bc16-siege .ladder { color: #b6a892; }
#bc16-siege .ladder .won { color: #f2e8d8; font-weight: 700; }

/* THE HUD MAY NOT BLEED THROUGH THE ENDCARD (the bc23 finding). */
html[data-year="bc16"] #endcard.show ~ #bc16-archons,
html[data-year="bc16"] #endcard.show ~ #bc16-horde,
html[data-year="bc16"] #endcard.show ~ #bc16-econ,
html[data-year="bc16"] #endcard.show ~ #bc16-units,
html[data-year="bc16"] #endcard.show ~ #bc16-doctrines {
/* THE HUD MAY NOT BLEED THROUGH THE ENDCARD (the bc23 finding).
PARENT-SCOPED, and both halves of that matter (r1-F1). The rule shipped
keyed on a `.show` class and a general-sibling combinator, and it was dead
twice over: the class the page actually toggles is `.on`
(`#endcard.on { display: flex; }` above, `$('endcard').classList.add('on')`
in renderEndcard), never `.show`; and `~` selects only FOLLOWING siblings
while the `#bc16-*` boxes are emitted BEFORE `#endcard` inside `#chrome`,
so no sibling combinator could ever have matched whatever class it named.
`#chrome:has(#endcard.on)` is direction-free and keys on the class that
exists.
The gate for this is a COMPUTED STYLE, not a text grep:
`tools/ci/renderer_fixture.html` raises `#endcard` and reads
`getComputedStyle(...).visibility` on all five boxes, so a rule that cannot
match is RED. A grep could not see the two defects above and did not. */
html[data-year="bc16"] #chrome:has(#endcard.on) #bc16-archons,
html[data-year="bc16"] #chrome:has(#endcard.on) #bc16-horde,
html[data-year="bc16"] #chrome:has(#endcard.on) #bc16-econ,
html[data-year="bc16"] #chrome:has(#endcard.on) #bc16-units,
html[data-year="bc16"] #chrome:has(#endcard.on) #bc16-doctrines {
visibility: hidden;
}

Expand Down Expand Up @@ -5644,6 +5696,7 @@
var pinned = false; // the viewer opened it themselves
var lastFrame = -1;
var lastArchons = [-1, -1];
var lastState = null; // the frame the strip re-renders from
var feedShown = {};

function $(id) { return document.getElementById(id); }
Expand Down Expand Up @@ -5779,10 +5832,57 @@
}

// ---- the horde clock: the year's signature readout --------------------
// THE STRIP TAKEOVER (r1-F9). On a wave round or a `turned` event the strip
// carries what happened in plain words for two seconds, and `.struck`
// dresses it -- the design note's `WAVE — 34 zombies from 4 dens at ×1.7`
// and `CLAN ASH'S ARCHON TURNS — a BIGZOMBIE at 34,19`. The words are the
// BEAT'S OWN LABEL, so the strip, the killfeed and the scrubber button all
// say the same sentence and there is no second string to keep in step.
// `.struck` is a static rule rather than an animation, so it takes a timer
// where the archon pill's `flash` takes an animation; the add/remove shape
// is renderArchons's.
var struckUntil = 0;
var struckText = '';
var struckTimer = null;
var struckAt = -1;

function hordeStrike(s, prevFrame) {
if (!s.beats || prevFrame < 0 || s.t <= prevFrame) return;
var hit = null;
s.beats.forEach(function (b) {
if (b.k !== 'wave' && b.k !== 'turned') return;
if (b.t <= prevFrame || b.t > s.t || !b.label) return;
hit = b; // the last one inside this step wins
});
if (!hit || hit.t === struckAt) return;
struckAt = hit.t;
struckText = hit.label;
struckUntil = Date.now() + 2000;
if (struckTimer) clearTimeout(struckTimer);
struckTimer = setTimeout(function () {
struckTimer = null;
struckUntil = 0;
var box = $('bc16-horde');
if (box) box.classList.remove('struck');
if (lastState) renderHorde(lastState);
}, 2000);
}

function renderHorde(s) {
var box = $('bc16-horde');
var h = s.bc16_horde;
if (!box || !h) return;
// THE THIRD PARTY IS NAMED ON SCREEN, at every width and in every state:
// the zombie team is drawn and labelled THE HORDE, is never a seat and
// never scores.
var who = '<span class="horde">THE HORDE</span>';
if (struckUntil > Date.now()) {
box.classList.add('struck');
box.innerHTML = who + '<span class="tell">' + esc(struckText) +
'</span>';
return;
}
box.classList.remove('struck');
var alive = h.alive || {};
var live = '\u25c6' + stat(alive.standardzombie, 'int') +
' \u27a4' + stat(alive.rangedzombie, 'int') +
Expand All @@ -5802,7 +5902,7 @@
var tl = (h.schedule || []).map(function (row) {
return row.past ? '<b>\u00b7</b>' : '\u00b7';
}).join('');
box.innerHTML =
box.innerHTML = who +
'<span class="alive">' + live + '</span>' +
'<span class="wave">' + esc(wave) + '</span>' +
'<span class="ob">OUTBREAK ' + stat(h.outbreak_level, 'int') +
Expand Down Expand Up @@ -5885,7 +5985,14 @@
}
html += '<br>' + esc((d.words || []).join(' \u00b7 '));
if (d.motto) html += '<br>\u201c' + esc(d.motto) + '\u201d';
if (d.notes) html += '<br>' + esc(d.notes);
// `<i>` is not decoration: it is the element every other year's
// doctrine row wraps its `notes` in, and it is what
// `tools/ci/renderer_fixture.html`'s "the notes on seat N were
// shortened before they were measured" check selects
// (`#<year>-doctrines .dline i`). bc25 shipped its own class names
// first and that check found nothing to measure; a bare text node
// here would do the same to bc16 (r1-F2).
if (d.notes) html += '<br><i>' + esc(d.notes) + '</i>';
if (d.submitted) {
html += '<br><span class="dsub">what the cog actually sent: ' +
esc(d.submitted) + '</span>';
Expand Down Expand Up @@ -5985,6 +6092,10 @@
if (lastFrame >= 0 && s.t > lastFrame && !pinned && !dismissed) {
setDoctrinesOpen(false);
}
// The strip takeover reads the frame the playhead came FROM, so it has
// to be decided before `lastFrame` moves.
lastState = s;
hordeStrike(s, lastFrame);
lastFrame = s.t;
renderArchons(s);
renderHorde(s);
Expand Down
55 changes: 55 additions & 0 deletions docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1812,6 +1812,26 @@ zombies and 9 infected pairs, and it additionally requires
`bc16idle` and 685 239 for `bc16greenhorn` — **1 667 162 lines compared, all
identical.**

### Divergence from the design note's literal anti-vacuity assertion (r1-F6)

The design note (design.md:2574) asked that `ci.yml` "additionally asserts
every game reached at least **2 900 rounds** and that at least **150 zombies**
were spawned". **Neither literal assertion is what shipped, and neither could
be**, so the substitution is recorded here rather than left implied.

| | design note | shipped (`ci.yml`) | why |
|---|---|---|---|
| round floor | ≥ 2 900 **per game** | `≥ 250` per game (`ci.yml:3069`) | **no oracle bot survives that long.** Measured lifetimes: `bc16idle` 298–683 rounds, `bc16greenhorn` 485–1424. A 2 900-round floor would fail every one of the eighteen pairs. |
| zombie floor | ≥ 150 **spawned per game** | `≥ 150` **summed `zombies_peak` over the eighteen pairs** (`ci.yml:3115`), measured 735 | the trace records the peak simultaneously on the board, not a spawn total; summing it over the pairs is the same anti-vacuity guarantee against the number the trace actually carries. |

What makes the shorter window sound rather than weaker is that the trace runs
from round 0 to the engine's own `isRunning() == false`, so **the end round,
the winner and the domination factor are themselves compared** — a port that
ended one round early diverges on the `W` line. The reasoning is also in the
workflow at `ci.yml:3013-3023`. The job additionally floors peak robots at 10
and requires an infection on at least 9 of the 18 pairs plus
`saw_zombie_turn=true` on every pair, none of which the note asked for.

## The measured bytecode headroom, and why it matters

V1 pins the port's delay decay to the `1.0` branch of the engine's
Expand Down Expand Up @@ -1840,6 +1860,41 @@ the bc26 run, not this script's preference. Every ledger entry must name a
round, a map and a root cause; a cause of `unknown` is rejected by the schema
check. `tools/ci/parity_ledger_bc16.json` is `{"entries": []}`.

## The hash chain's packed fields, and the year-neutral half NOT changed (r1-F7)

bc16's per-round hash chain is a **tripwire**: `src/battlecode/replay.nim:303-312`
compares it frame by frame against the recording, and a collision can only
*hide* a divergence, never manufacture one. It shipped with three packed
values, and a packed field that overflows is a blind spot in the tripwire:

* `src/battlecode/years/bc16/rules.nim` folded the six player-type censuses
base-100/base-1000000 into two `mixHash` calls and the four zombie censuses
base-100 into one. **Any single count of 100 or more carried into the next
field**, so two distinct censuses could fold to the same chain value. That
was not hypothetical here: the parity job measures `peak_robots` of
**104–162** and the survival gate builds **177–212** units a seat on
`checkers`/`prisons`. **FIXED**: every count is now its own `mixHash` call
(nineteen per-team values and thirteen globals), and
`tests/fixtures/replay-bc16.json` was re-recorded against the new chain with
`tools/gen_bc16_fixture_replay.nim`. No assertion was weakened to make the
new fixture pass; a new one was added
(`tests/test_bc16_replay.nim`) that re-derives the committed fixture to its
**last** round rather than to the 200-frame prefix
`tools/wasm_replay_smoke.cjs` walks.

* `src/battlecode/match.nim:498-506` packs `archons` `div/mod 100` and
`parts_worth` `div/mod 100000` in the year-neutral results writer. **This is
DELIBERATELY NOT CHANGED**, and the limit is recorded rather than fixed:
those fields are year-neutral, so widening them would move the committed
chain values of **every sibling year** (bc20–bc26) and require re-recording
seven more fixtures — a cross-year edit far beyond what a bc16 round
authorises. The margin is finite but large on the evidence: `archons` is
bounded by the maps' archon counts (≤ 4 a side in the played pool) and
`parts_worth` = `int(parts) + Σ partCost` over live robots, against the
largest played map's 20 520 parts (`quadrants`) and a 100 000 field. **A
future year whose census can exceed 99 archons or whose economy can exceed
100 000 parts-worth must widen these two fields before it ships.**

## Tier A′ — NOT IMPLEMENTED, and named here rather than left implied

The design note specified a **Tier A′** of four scenario bots
Expand Down
59 changes: 53 additions & 6 deletions docs/RULES-BC16.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,60 @@ Every place this port deliberately differs from the engine, with its reason.
killed across the six maps, a median-round floor, and **the whole gate
coming back RED under `-d:bc16BrokenChassis`**. Both measured tables —
healthy and broken — are inline in `tests/test_bc16_survival.nim`'s header
and repeated here:

| | not `archons_destroyed` | dens killed | median rounds | guards (min) |
and repeated here.

**Provenance, so a future staleness is detectable (r1-F4):** these numbers
were regenerated from the build that shipped and agree with `ci.yml` run
**34322655506** (`main` @ `fbc7d345`, `test` job 102372608026), whose own
log line reads `HEALTHY games=6 notDestroyed=3 dens=14 median=2015
rounds=@[3000, 695, 3000, 707, 3000, 1030]`, and `median=873` for the
control. The rows previously printed here were measured before the
den-breaking chassis iteration landed and were stale by 20 vs 14 dens,
2147 vs 2015 median rounds and 1029 vs 873 on the control.

| | not `archons_destroyed` | dens killed | median rounds | guards (min) | units (min) | damage (min) |
|---|---|---|---|---|---|---|
| healthy, after the den-breaking iteration | **3 of 6** | **14** | **2015** | 5 | 58 | 3608 |
| healthy, before it | 1 of 6 | 6 | 936 | 4 | — | — |
| broken (`-d:bc16BrokenChassis`) | **0 of 6** | **0** | **873** | **0** | 55 | 3333 |

Every committed floor still holds on the regenerated numbers:
`MinNotDestroyed` 2 ≤ 3, `MinUnitsBuilt` 25 ≤ 58, `MinDamageDealt`
1500 ≤ 3608, `MinGuardsBuilt` 2 ≤ 5, `MinDensKilled` 4 ≤ 14,
`MinMedianRounds` 1000 ≤ 2015, parts income 1 ≤ 11 724 tenths, and
`swamp`'s pair-parts clause 1 ≤ 1 200 tenths. **No floor was moved.**

**Which clauses carry the discrimination, and which are anti-degeneracy
floors the broken control also clears (r1-F3).** Stated plainly, because a
floor set below what the named control already achieves does not
discriminate, and reading the clause list as if every clause did is the
mistake. The floors are **not fitted to the control**: raising the damage
floor past the control's 3333 would put it above the *healthy* weak seat's
3608, which is fitting a floor to noise and would redden healthy runs.

| clause | floor | healthy (worst) | broken control | discriminates? |
|---|---|---|---|---|
| healthy, after the den-breaking iteration | **3 of 6** | **20** | **2147** | 5 |
| healthy, before it | 1 of 6 | 6 | 936 | 4 |
| broken (`-d:bc16BrokenChassis`) | **0 of 6** | **0** | 1029 | **0** |
| ratio, not `archons_destroyed` | 2 of 6 | 3 of 6 | **0 of 6** | **yes** |
| guards built, per seat per game | 2 | 5 | **0** | **yes** |
| dens killed across the six maps | 4 | 14 | **0** | **yes** |
| `swamp` parts collected across the pair | 1 tenth | 1 200 | **0** | **yes** |
| units built, per seat per game | 25 | 58 | 55 | no — anti-degeneracy only |
| damage dealt, per seat per game | 1 500 | 3 608 | 3 333 | no — anti-degeneracy only |
| parts income, per seat per game | 1 tenth | 11 724 | 10 621 | no — anti-degeneracy only |
| median rounds | 1 000 | 2 015 | 873 | inside the control's noise band |

The four `yes` clauses are a **hard zero** on the control and carry all of
the gate's discriminating power. The three `no` clauses stay asserted
because what they catch is a chassis that stops acting *at all* — the
do-nothing sheet that wins because the opponent starved (the 2026-09-03
finding) — not this particular control. **The median floor sits inside the
control's own noise band**: the previous session recorded 1 029, *above*
the 1 000 floor, and the shipped build measures 873, *below* it, so it is
counted with the anti-degeneracy floors even though it does fire today.
Run 34322655506's control failed on sixteen clauses and the eight it
printed are all `guards built 0 < 2` plus `swamp: parts collected across
the pair (tenths) 0 < 1` — not one printed failure is a units-built,
damage-dealt, income or median failure.

17. **The chassis reads the den roster from the map** rather than
rediscovering it by sighting. The whole-map zombie schedule is **public**
Expand Down
Loading
Loading