From 3bee5ee8128b4547d9c57e8311647cf0d1fc1836 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:41 -0700 Subject: [PATCH 01/15] r1-F1: the fixture's doctrine-panel rule measures the year's own panel The "the doctrine panel may not take half the frame height" verdict in tools/ci/renderer_fixture.html read `#doctrines` unconditionally. Under `data-year="bc20"` that element is `display: none !important` (client/replay_broadcast.html:2653), so its rect is 0x0 and the rule passed vacuously on all three bc20 rows. It now measures `probe`, the same year-aware id the CSS-loaded check already resolves. Evidence: with the threshold temporarily forced to 0, the fixture reports three `the doctrine panel takes N%` errors before this change (the bc26 rows only) and six after -- the bc20 rows now measure 17-20% of their frame. At the shipped 0.5 threshold the fixture is green. --- tools/ci/renderer_fixture.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/ci/renderer_fixture.html b/tools/ci/renderer_fixture.html index 43c83ad..3d7f420 100644 --- a/tools/ci/renderer_fixture.html +++ b/tools/ci/renderer_fixture.html @@ -333,7 +333,10 @@ // both seats, at 360 px, the doctrine panel may not take half the frame: // that is how a working replay came to look like a still image in two // live screenshots a minute apart. - var panel = document.getElementById('doctrines').getBoundingClientRect(); + // `probe` is the year's own doctrine panel: under data-year="bc20" the + // inherited #doctrines is `display: none !important`, so measuring it + // there would report a 0x0 rect and pass this rule vacuously (r1-F1). + var panel = document.getElementById(probe).getBoundingClientRect(); if (panel.height > frame.height * 0.5) { return 'the doctrine panel takes ' + Math.round((panel.height / frame.height) * 100) + '% of the frame ' + From d12f35af884e5b15b7e772836f66c97c3e75278e Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:44 -0700 Subject: [PATCH 02/15] r1-F2: drone_water_drop names the dropped unit's own owner `dropUnit`/`dropHeldUnit` emit the event for ANY unit dropped onto a flooded tile -- a friendly landscaper or a neutral cow as readily as an enemy -- but match.nim built `victim_alias` as `aliasOfTeam(1 - e.b)`, i.e. always the other clan. The kill-feed line and the `drop` beat therefore named the wrong victim on a friendly or cow drop. The event now carries the dropped unit's team ordinal in its string slot and match.nim maps it: clan alias for 0/1, `neutral` for a cow. No counter, no hash-chain input and no results key changes. tests/test_bc20_drone.nim gains a friendly-drop and a cow-drop case and asserts the victim team on the existing enemy-drop case (33 checks, was 28). --- src/battlecode/match.nim | 10 +++++++- src/battlecode/years/bc20/world.nim | 6 +++-- tests/test_bc20_drone.nim | 37 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/battlecode/match.nim b/src/battlecode/match.nim index 70a72fe..8ef1735 100644 --- a/src/battlecode/match.nim +++ b/src/battlecode/match.nim @@ -126,9 +126,17 @@ proc collectGameEvents( fields = %*{"alias": plan.aliasOfTeam(gameIndex, e.a), "units": e.b})) of "drone_water_drop": + ## A drone drops whatever it is holding, which may be its own unit or a + ## neutral cow, so the victim's TEAM rides on the event (`e.s`) rather + ## than being assumed to be the other clan. + let victimAlias = + case e.s + of "0": plan.aliasOfTeam(gameIndex, 0) + of "1": plan.aliasOfTeam(gameIndex, 1) + else: "neutral" events.add(ev("drone_water_drop", game = gameIndex, round = e.round, fields = %*{"alias": plan.aliasOfTeam(gameIndex, e.b), - "victim_alias": plan.aliasOfTeam(gameIndex, 1 - e.b), + "victim_alias": victimAlias, "victim_unit": Bc20UnitNames[e.c]})) else: discard diff --git a/src/battlecode/years/bc20/world.nim b/src/battlecode/years/bc20/world.nim index d458211..f69cf72 100644 --- a/src/battlecode/years/bc20/world.nim +++ b/src/battlecode/years/bc20/world.nim @@ -737,7 +737,8 @@ proc dropHeldUnit*(w: World, drone: Robot, target: Loc) = if w.isFlooded(target): if dropped.team != drone.team and drone.team != teamNeutral: w.stats.droneWaterDrops[ord(drone.team)] += 1 - w.emit("drone_water_drop", drone.id, ord(drone.team), ord(dropped.kind)) + w.emit("drone_water_drop", drone.id, ord(drone.team), ord(dropped.kind), + $ord(dropped.team)) w.destroyRobot(id) proc dropUnit*(w: World, r: Robot, d: Dir) = @@ -754,7 +755,8 @@ proc dropUnit*(w: World, r: Robot, d: Dir) = if w.isFlooded(target): if dropped.team != r.team and r.team != teamNeutral: w.stats.droneWaterDrops[ord(r.team)] += 1 - w.emit("drone_water_drop", r.id, ord(r.team), ord(dropped.kind)) + w.emit("drone_water_drop", r.id, ord(r.team), ord(dropped.kind), + $ord(dropped.team)) w.destroyRobot(id) proc canShootUnit*(w: World, r: Robot, id: int): bool = diff --git a/tests/test_bc20_drone.nim b/tests/test_bc20_drone.nim index c03f585..975e658 100644 --- a/tests/test_bc20_drone.nim +++ b/tests/test_bc20_drone.nim @@ -18,6 +18,13 @@ proc flat(width, height, elevation: int, wet: seq[int] = @[]): MapSpec = proc ready(w: World, r: Robot) = r.cooldownTurns = 0.0'f32 +proc lastWaterDropVictim(w: World): string = + ## The `drone_water_drop` event carries the DROPPED unit's team ordinal in + ## its string slot; `match.nim` turns that into `victim_alias`. + result = "none" + for e in w.events: + if e.kind == "drone_water_drop": result = e.s + block: ## Pickup radius squared is 3, and only UNITS may be lifted. var w = newWorld(flat(11, 11, 0), 1500) @@ -85,6 +92,36 @@ block: w.dropUnit(drone, dSouth) check("the enemy landscaper drowned", riderId notin w.robotsById) checkEq("and it is recorded as a water drop", w.stats.droneWaterDrops[0], 1) + checkEq("and the event names the victim's own team", w.lastWaterDropVictim(), + $ord(teamB)) + +block: + ## A drone may drop its OWN unit, or a neutral cow, into the water. The + ## event names whoever was dropped — never "the other clan" by assumption — + ## and neither drop moves the enemy-drop counter. + var w = newWorld(flat(11, 11, 0, @[5 + 11 * 4]), 1500) + let droneId = w.spawnRobot(rtDeliveryDrone, loc(5, 5), teamA) + let friendId = w.spawnRobot(rtLandscaper, loc(6, 5), teamA) + let drone = w.robotsById[droneId] + w.ready(drone) + w.pickUpUnit(drone, friendId) + w.ready(drone) + w.dropUnit(drone, dSouth) + check("the friendly landscaper drowned too", friendId notin w.robotsById) + checkEq("the event names the friendly team as the victim", + w.lastWaterDropVictim(), $ord(teamA)) + checkEq("and no enemy water drop was counted", w.stats.droneWaterDrops[0], 0) + + let cowId = w.spawnRobot(rtCow, loc(6, 5), teamNeutral) + w.ready(drone) + w.pickUpUnit(drone, cowId) + w.ready(drone) + w.dropUnit(drone, dSouth) + check("the cow drowned", cowId notin w.robotsById) + checkEq("the event names the neutral team as the victim", + w.lastWaterDropVictim(), $ord(teamNeutral)) + checkEq("and the counter, which counts every unit that is not the drone's " & + "own, moved", w.stats.droneWaterDrops[0], 1) block: ## A dying drone drops its cargo on its OWN tile — and the cargo drowns if From 9f7e6b14dbf0a157f0cabd5ef24258bd34042244 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:45 -0700 Subject: [PATCH 03/15] r1-F5: the ignored-`chassis` log line names the seat's real chassis decide.nim's branch for a reply that sends a `chassis` key printed "the clan runs the awu chassis" on every year. On bc20 the seat runs `bowl-of-chowder` (or the baseline its PLAYER_SCRIPTED names), so the line misinformed the operator on exactly the episode it exists to explain. It now prints `chassisNameFor(config.year, seats[slot], sheet)` -- the same resolution server.nim records on the seat. The record-and-never-honour behaviour is unchanged and still asserted by tests/test_bc20_sheet.nim. --- src/battlecode/decide.nim | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/battlecode/decide.nim b/src/battlecode/decide.nim index 72f4d17..62d02b7 100644 --- a/src/battlecode/decide.nim +++ b/src/battlecode/decide.nim @@ -308,12 +308,15 @@ proc decide*( result.fallback[slot] = "" ## `chassis` is not a knob (sheet.KnownKeys). A reply that still sends ## one is already recorded in `unknownFields` and ignored — the clan - ## runs `awu` — but a silent ignore is how round 1's champion came to - ## idle three games, so the seat that tried is named in the log. + ## runs the chassis the OPERATOR fixed — but a silent ignore is how + ## round 1's champion came to idle three games, so the seat that tried + ## is named in the log, along with the chassis it actually drives. if "chassis" in result.sheets[slot].unknownFields: echo "battlecode llm: seat ", slot, " sent `chassis`, which is not a doctrine knob: ignored, the clan", - " runs the awu chassis" + " runs the ", + chassisNameFor(config.year, seats[slot], result.sheets[slot]), + " chassis" result.events.add(ev("doctrine_received", ms = latency, fields = %*{ "slot": slot, "attempt": attempt + 1, "latency_ms": latency, "defaults_applied": result.sheets[slot].defaultsApplied.len, From 3224c511a25ac63eea6d5f6b01207eeea0f796e1 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:48 -0700 Subject: [PATCH 04/15] r1-F7: fulfillment.nim's header stops claiming a NEED_DRONES branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runFulfillmentCenter` implements only the roster/pool test; the module doc comment also promised "and always when `NEED_DRONES` is on the chain", which no code path provides -- nothing broadcasts `SigNeedDrones` and `readBlocks` drops it into `else: discard`. Documented rather than implemented: the branch would guard a signal that never arrives, and inventing a broadcaster is a play change, not a comment fix. The header now says what the code does, `SigNeedDrones` is marked RESERVED with the reason its code point is kept (renumbering the table would change the meaning of every recorded message), and docs/RULES-BC20.md gains §Divergences item 15. No behaviour change. --- docs/RULES-BC20.md | 8 ++++++++ src/battlecode/years/bc20/chassis/fulfillment.nim | 10 ++++++++-- src/battlecode/years/bc20/chassis/signals.nim | 4 ++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/RULES-BC20.md b/docs/RULES-BC20.md index 51728ba..becb8ce 100644 --- a/docs/RULES-BC20.md +++ b/docs/RULES-BC20.md @@ -182,6 +182,14 @@ Every one of these is deliberate and is the reason the parity oracle compares spawn. `TeamControlProvider` delegates `robotSpawned` **by team**, and only the cow provider recomputes; cows exist only at map load, so the value is fixed for the match. The port reproduces the delegation, not the prose. +15. **The Fulfillment Center has no `NEED_DRONES` branch.** The design note has + it build "whenever the roster is under `4 + round/300` (capped 14) and the + pool can pay, and always when `NEED_DRONES` is on the chain". Only the + first half is implemented: no role in this chassis ever broadcasts + `NEED_DRONES`, so the second branch would guard a signal that never + arrives. `SigNeedDrones = 5` keeps its code point — renumbering the signal + table would change the meaning of every message in every recorded match — + and is marked reserved in `chassis/signals.nim`. ## Where the archetypes come from diff --git a/src/battlecode/years/bc20/chassis/fulfillment.nim b/src/battlecode/years/bc20/chassis/fulfillment.nim index 46ca5d5..d203789 100644 --- a/src/battlecode/years/bc20/chassis/fulfillment.nim +++ b/src/battlecode/years/bc20/chassis/fulfillment.nim @@ -1,6 +1,12 @@ ## The Fulfillment Center: build a Delivery Drone whenever the roster is under -## `4 + round/300` (capped 14) and the pool can pay, and always when -## `NEED_DRONES` is on the chain. +## `4 + round/300` (capped 14) and the pool can pay. +## +## The design note's second clause — "and always when `NEED_DRONES` is on the +## chain" — is NOT implemented: nothing in this chassis broadcasts +## `NEED_DRONES`, so the branch would be dead code guarding a signal that never +## arrives. `SigNeedDrones` keeps its code point (removing it would renumber +## the signal table and change every recorded message) and is marked reserved +## in `signals.nim`. §Divergences item 15 in `docs/RULES-BC20.md`. ## ## Behaviour, not code, from `StoneT2000/Battlecode2020` (AGPL-3.0; see NOTICE). diff --git a/src/battlecode/years/bc20/chassis/signals.nim b/src/battlecode/years/bc20/chassis/signals.nim index c7f0480..da86a87 100644 --- a/src/battlecode/years/bc20/chassis/signals.nim +++ b/src/battlecode/years/bc20/chassis/signals.nim @@ -20,6 +20,10 @@ const SigWallIn* = 3 SigHqUnderAttack* = 4 SigNeedDrones* = 5 + ## RESERVED. Nothing broadcasts it and `readBlocks` does not act on it: the + ## Fulfillment Center builds off its own roster count. The code point is + ## kept so the table's numbering — which every recorded message carries — + ## does not move. §Divergences item 15 in `docs/RULES-BC20.md`. SigRushNow* = 6 SigWallClosed* = 7 From d046fc124aa03fbabcc2c425bd98df2cfc8a026c Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:49 -0700 Subject: [PATCH 05/15] r1-F3: document where rules_digest and sheet_schema actually ship The design note's per-seat observation sample carries `rules_digest` and `sheet_schema`; `briefFor` emits neither. Both contents do reach every seat -- they are in `Bc20Preamble`, the system message, which the replay records once as `prompt_preamble` rather than twice inside `seats[].prompt`. Documented rather than changed: duplicating the ~7 KB digest and the whole knob table into each seat's recorded observation would grow every replay for no new information. docs/PROTOCOL.md's bc20 observation section now names both keys, says where they live, and tells a consumer which field to read. --- docs/PROTOCOL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index a42c792..6840558 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -160,6 +160,15 @@ payload differs in three places: the water never reaches it inside the 1500-round cap; * **`scoring`** carries bc20's three weights instead of bc26's two weight sets. +**No `rules_digest` and no `sheet_schema` key.** The design note's sample +payload shows both inside the per-seat observation. They ship instead in the +**system preamble** (`decide.nim`'s `Bc20Preamble`), which every seat receives +as the system message and which the replay records once, at document level, as +`prompt_preamble` — the condensed rule set and the full knob surface with every +range and default are there in full, verbatim, for both years. The content a +doctrine sees is the same; only the layout differs. A consumer that wants the +knob surface off a replay reads `prompt_preamble`, not `seats[].prompt`. + **Hidden**, as ever: the opponent's doctrine, sheet, notes, motto, real name and fallback status; every in-match state (a cog receives **no** per-round observation). The only cross-team channel inside a match is the sim's own From 0fd26fbb680fc88b4bb748b7d6e6ad90ebe2dc31 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:52 -0700 Subject: [PATCH 06/15] r1-F4: reconcile flood_table["7"] = 1501 with the note's 1546 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `floodTableJson` reports `roundWaterReaches(7)`, which is the sentinel `WaterTableMaxRound + 1 = 1501` -- the committed water table covers rounds 0..1500 and the water never rises above elevation 7 inside the cap. The design note's payload shows 1546, the uncapped curve's real value, which is also what §Divergences item 4 of docs/RULES-BC20.md states. Documented rather than changed: recording the true 1546 would mean generating and committing water levels for rounds the sim can never play, and the committed table is byte-diffed against the JDK generator as a blocking CI step. docs/PROTOCOL.md now names both numbers, says which one the payload carries and why, and `floodTableJson`'s doc comment points at it. Levels 1-6 are unchanged and still pinned by tests/test_bc20_flood.nim. --- docs/PROTOCOL.md | 11 +++++++++-- src/battlecode/years/dispatch.nim | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 6840558..e4733a4 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -156,8 +156,15 @@ payload differs in three places: * **the map cards** carry the seat's own HQ starting elevation and the HQ separation, which is what a doctrine has to plan the wall against; * **`flood_table`** says which round each integer elevation floods at — the - single most important fact in the year. Elevation 7 reports `1501` because - the water never reaches it inside the 1500-round cap; + single most important fact in the year. Levels 1–6 are the real curve + (256 / 464 / 677 / 931 / 1210 / 1413). Elevation 7 reports **1501**, which is + not a round the water arrives at but the sentinel `WaterTableMaxRound + 1` + that `flood.roundWaterReaches` returns when the committed table — rounds + 0…1500, the whole of the capped game — never rises above that level. On the + uncapped curve elevation 7 floods at round **1546** (§Divergences item 4 in + `docs/RULES-BC20.md`, and the design note's own payload); the sim cannot + reach it, so the table does not carry it. Either number tells a doctrine the + same thing: elevation 7 is dry for the whole match; * **`scoring`** carries bc20's three weights instead of bc26's two weight sets. **No `rules_digest` and no `sheet_schema` key.** The design note's sample diff --git a/src/battlecode/years/dispatch.nim b/src/battlecode/years/dispatch.nim index 2ff2184..2873143 100644 --- a/src/battlecode/years/dispatch.nim +++ b/src/battlecode/years/dispatch.nim @@ -257,6 +257,11 @@ proc playGameFor*( proc floodTableJson*(): JsonNode = ## The round each integer elevation floods at — the single most important ## fact a bc20 doctrine has to plan around, so it goes in the observation. + ## + ## Level 7 reports `WaterTableMaxRound + 1` (1501), the "never inside the + ## cap" sentinel `roundWaterReaches` returns: the uncapped curve reaches + ## elevation 7 at round 1546, which no 1500-round game can play. Said in + ## `docs/PROTOCOL.md` §The bc20 observation. result = newJObject() for level in 1 .. 7: result[$level] = %flood20.roundWaterReaches(level) From 98670d6fa6a51890fd3b7d6d7abbdd467024e350 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:54 -0700 Subject: [PATCH 07/15] r1-F6: declare the builder-miner's Refinery, and its off-ring net guns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `nextBuilding` inserts a Refinery second and sites every building at Chebyshev 2 (4 for the Refinery). The design note's order has no Refinery and puts the net guns on the HQ ring. The net-gun move was in the build report and in miner.nim's header, which pointed at docs/RULES-BC20.md -- where no such entry existed; the Refinery was explained only in a comment at the branch. Both are now §Divergences item 16 with the rule that forces each: a walled HQ is eight elevation steps up against MAX_DIRT_DIFFERENCE 3, so miners need a second drop-off the moment the wall closes; and dirt dropped on a building buries it, so a net gun on the ring is buried by its own wall. miner.nim's header now lists the Refinery it actually builds. No behaviour change. --- docs/RULES-BC20.md | 16 ++++++++++++++++ src/battlecode/years/bc20/chassis/miner.nim | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/docs/RULES-BC20.md b/docs/RULES-BC20.md index becb8ce..7a68c38 100644 --- a/docs/RULES-BC20.md +++ b/docs/RULES-BC20.md @@ -190,6 +190,22 @@ Every one of these is deliberate and is the reason the parity oracle compares arrives. `SigNeedDrones = 5` keeps its code point — renumbering the signal table would change the meaning of every message in every recorded match — and is marked reserved in `chassis/signals.nim`. +16. **The builder-miner's order carries a Refinery, and its net guns stand off + the HQ ring.** The design note's order is: Design School → `net_gun_ring` + Net Guns *on the HQ ring* → Fulfillment Center → Vaporators → a second + Design School after round 600. What `chassis/miner.nim` builds is Design + School → **Refinery** → Net Guns → Fulfillment Center → Vaporators → second + Design School, with every building at Chebyshev 2 from the own HQ and the + Refinery at Chebyshev 4. Both moves are forced by rules the note's order + fights: + * a **walled** HQ sits eight elevation steps above the ground outside its + ring and `MAX_DIRT_DIFFERENCE` is 3, so once the wall closes a miner can + no longer climb to the HQ to deposit. Without a second drop-off the + economy stops at exactly the moment the wall succeeds. The Refinery also + refines its own 20 a round; + * **dirt dropped on a building buries it** (rule 6.6), and the HQ ring is + precisely what the landscapers raise. A net gun on the ring is buried by + its own team's wall, so the ring is the one place it may not stand. ## Where the archetypes come from diff --git a/src/battlecode/years/bc20/chassis/miner.nim b/src/battlecode/years/bc20/chassis/miner.nim index 05b0ae6..7e57b73 100644 --- a/src/battlecode/years/bc20/chassis/miner.nim +++ b/src/battlecode/years/bc20/chassis/miner.nim @@ -5,12 +5,16 @@ ## and needs no signalling. It builds, in this order and only when the team ## pool can afford it without stalling miner production: ## 1 Design School at Chebyshev 2 from the HQ, on the side away from the water +## 1 Refinery at Chebyshev 4 — the second drop-off a walled-in HQ needs ## `net_gun_ring` Net Guns, also at Chebyshev 2 (a net gun ON the HQ ring ## would be buried by our own wall — see docs/RULES-BC20.md) ## 1 Fulfillment Center ## `vaporator_budget` Vaporators inside the lattice ## a second Design School after round 600 ## +## The design note's order has no Refinery and puts the net guns on the HQ +## ring; both moves are §Divergences item 16 in `docs/RULES-BC20.md`. +## ## Behaviour, not code, from `StoneT2000/Battlecode2020` (AGPL-3.0; see NOTICE). import kit, pathing, signals, lattice From 925ef4fa7ba0a2087cad1d02b769ae27bf9f606d Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:55 -0700 Subject: [PATCH 08/15] r1-F8: the end-reason coverage check stops passing on string literals The loop claimed "record -> re-derive covered every end reason" while three of the seven entries were `seenReasons.add("...")` literals, `quality` was a world-level ladder vector, and `abandoned` was added OUTSIDE the `if reason == epDeadline` branch. The branch never fires: a 1499-round CentralSoup game re-derives in 268 ms in release, so a one-second guard cannot trip, and the coverage loop passed on a path that had never run. Now: * two lists, `reDerived` and `ladderVector`, and the loop names which one each reason must be in -- a reason nobody produced can no longer satisfy it; * `broadcasts`, `highest_id` and `coin_flip` are real vectors through the same `checkEndOfMatch` a played game calls, on a bare world (the committed maps arrive with their own HQs and cows, which is why the last rungs need one); * `abandoned` is end-to-end and deterministic: the recorder's own guard is made to fire by holding the clock in `playGame`'s round callback -- the real code path, not a mock -- and the resulting stop round is written as `plan.abandon_after[0]`, re-derived from the written bytes, and the deriver's hash chain at the stop round is compared against the recorder's own `roundChains` entry. The abandoned game carries no GameHeader, so that comparison is the only thing that proves the two agree. 59 checks, up from 45. Nothing was removed or loosened. --- tests/test_bc20_replay.nim | 164 ++++++++++++++++++++++++++----------- 1 file changed, 116 insertions(+), 48 deletions(-) diff --git a/tests/test_bc20_replay.nim b/tests/test_bc20_replay.nim index 791ea1e..165dc2d 100644 --- a/tests/test_bc20_replay.nim +++ b/tests/test_bc20_replay.nim @@ -8,7 +8,7 @@ ## in each, because both of those are written against bc26's own world type and ## a year-neutral rewrite of them would touch bc26 for no reason. -import std/[json, strutils, unicode] +import std/[json, os, strutils, unicode] import harness import battlecode/[baselines, broadcast, match, replay, results, sheet, sim_types] @@ -92,7 +92,27 @@ proc bc20Config(rounds = 400, games = 1, pool = "small"): GameConfig = result.gamesPerMatch = games result.maxRounds = rounds -var seenReasons: seq[string] +proc bare(): World = + ## An empty 15x15 world for the ladder rungs a played game cannot reach: the + ## committed maps arrive with their own HQs and cows, and the last three + ## rungs need an exact roster. + var spec = MapSpec(name: "flat", width: 15, height: 15, + symmetry: symRotational, randomSeed: 4242) + for i in 0 ..< 15 * 15: + spec.elevation.add(0) + spec.water.add(false) + spec.pollution.add(0) + spec.soup.add(0) + newWorld(spec, 1500) + +## Which end reasons this shard proves, and how. A rung that only a contrived +## world can reach cannot be produced by a scripted game, so it is proved by a +## LADDER VECTOR through the same `checkEndOfMatch` a played game calls; the +## rungs a played game does reach are proved by a full record → re-derive of +## the written bytes. The coverage check below names which list each reason is +## in, so it can never pass on a string nobody produced. +var reDerived: seq[string] ## recorded, written, re-derived, no mismatch +var ladderVector: seq[string] ## produced by `checkEndOfMatch` in this shard block: ## `quantity`: the round cap with both HQs standing. @@ -101,7 +121,7 @@ block: check("and re-derives with no hash mismatch", r.ok) checkEq("one game was recorded", r.games.len, 1) checkEq("and it ended on the round limit", r.games[0].endReason, "quantity") - seenReasons.add(r.games[0].endReason) + reDerived.add(r.games[0].endReason) block: ## `hq_destroyed`: the scaffold drowns on `maptestsmall`, whose HQ ring sits @@ -112,7 +132,7 @@ block: checkEq("and it ended on an HQ", r.games[0].endReason, "hq_destroyed") checkEq("with the cause recorded as drowning", r.games[0].stats["hq_lost_cause"][1].getStr(), "drowned") - seenReasons.add(r.games[0].endReason) + reDerived.add(r.games[0].endReason) block: ## `quality`: equal robot counts, unequal net worth. Driven at the world @@ -124,59 +144,107 @@ block: w.currentRound = w.maxRounds - 1 w.checkEndOfMatch() checkEq("the quality rung is reachable", $w.domination, "quality") - seenReasons.add($w.domination) + checkEq("and the richer side wins", w.winner, teamA) + ladderVector.add($w.domination) + +block: + ## `broadcasts`: equal worth, more MINTED transactions. + var w = bare() + discard w.spawnRobot(rtHq, loc(1, 1), teamA) + discard w.spawnRobot(rtHq, loc(13, 13), teamB) + w.stats.blockchainsSent = [3, 1] + w.currentRound = w.maxRounds - 1 + w.checkEndOfMatch() + checkEq("the broadcasts rung is reachable", $w.domination, "broadcasts") + checkEq("and the chattier side wins", w.winner, teamA) + ladderVector.add($w.domination) + +block: + ## `highest_id`: the highest living NON-NEUTRAL robot id. The cow is ignored. + var w = bare() + discard w.spawnRobot(50_000, rtHq, loc(1, 1), teamA) + discard w.spawnRobot(50_001, rtHq, loc(13, 13), teamB) + discard w.spawnRobot(60_000, rtCow, loc(7, 7), teamNeutral) + w.currentRound = w.maxRounds - 1 + w.checkEndOfMatch() + checkEq("the highest_id rung is reachable", $w.domination, "highest_id") + checkEq("and the higher id wins", w.winner, teamB) + ladderVector.add($w.domination) block: - ## `broadcasts`, `highest_id` and `coin_flip` are the last three rungs. - ## `tests/test_bc20_scoring.nim` carries a vector for each; this shard only - ## records that they are producible so the coverage check below is honest. - seenReasons.add("broadcasts") - seenReasons.add("highest_id") - seenReasons.add("coin_flip") + ## `coin_flip`: reachable only when NEITHER team has a living robot, and + ## drawn from the world RNG rather than `Math.random()`. + var w = bare() + discard w.spawnRobot(60_000, rtCow, loc(7, 7), teamNeutral) + w.currentRound = w.maxRounds - 1 + w.checkEndOfMatch() + checkEq("the coin_flip rung is reachable", $w.domination, "coin_flip") + ladderVector.add($w.domination) block: - ## `deadline`: the wall-clock stop is RECORDED as ONE load-bearing value and - ## applied by the SAME proc on record and on playback (the particle-worlds - ## scar). A zero-second budget abandons the first game immediately. - var config = bc20Config(1500) - config.perGameBudgetSeconds = 1 - config.matchBudgetSeconds = 1 - var plan = buildPlan(config, sheets(), 9) + ## `abandoned`, end to end and DETERMINISTICALLY. The wall-clock guard is + ## the recorder's; `plan.abandon_after[g]` is the ONE load-bearing record of + ## it; and playback applies that record with the same proc. A 1500-round + ## game of this sim runs in a quarter of a second, so no honest budget makes + ## the guard fire on its own — the round callback holds the clock instead, + ## which is the recorder's real code path and not a mocked one. + let s = sheets() + let slow = proc (w: World, round: int) {.closure.} = sleep(40) + let (_, aborted) = playGame(loadMap("Hourglass"), s, Chassis, 0, 0, 400, 1, + slow) + check("the wall-clock guard fired", aborted.aborted) + checkEq("and the game is recorded as abandoned", aborted.endReason, + "abandoned") + check("at the first sampling point past the budget", + aborted.roundsPlayed > 0 and (aborted.roundsPlayed and 0x1F) == 0) + let stopAt = aborted.roundsPlayed + + var config = bc20Config(400) + var plan = buildPlan(config, s, 9) plan.chassis = Chassis - plan.maps = @["CentralSoup"] + plan.maps = @["Hourglass"] plan.sideAslots = @[0] - plan.abandonAfter = @[-1] + plan.abandonAfter = @[stopAt] var events: seq[MatchEvent] - let (games, reason) = playMatch(config, plan, events) - if reason == epDeadline: - checkEq("an abandoned game is DISCARDED, never scored half-played", - games.len, 0) - check("and the stop round is recorded", plan.abandonAfter[0] > 0) - var seats: array[2, SeatReport] - for slot in 0 .. 1: - seats[slot] = SeatReport(name: "s" & $slot, alias: aliasFor(slot), - policyKind: "scripted", sheet: sheets()[slot], - chassis: "bowl-of-chowder") - var doc = ReplayDoc(gameVersion: GameVersion, year: "bc20", - config: %*{"year": "bc20"}, seed: 9, seats: seats, events: events, - result: resultsJson(seats, games, plan, reason, 0.0, 0.0), plan: plan) - for slot in 0 .. 1: doc.names[slot] = "s" & $slot - let deriver = newDeriver(parseReplay($doc.toJson())) - while deriver.advance(): discard - checkEq("and playback stops exactly where the recorder stopped", - deriver.session.currentRound, plan.abandonAfter[0]) - else: - ## A one-second budget is generous for a 48x48 game on a fast runner; the - ## guard is still checked by the branch above when it fires. Record the - ## reason either way so the coverage check cannot pass vacuously. - checkEq("a game that beat the guard still completes", reason, epComplete) - seenReasons.add("abandoned") + events.add(ev("game_abandoned", game = 0, round = stopAt, + fields = %*{"map": plan.maps[0]})) + var seats: array[2, SeatReport] + for slot in 0 .. 1: + seats[slot] = SeatReport(name: "s" & $slot, alias: aliasFor(slot), + policyKind: "scripted", sheet: s[slot], + chassis: (if slot == 0: "bowl-of-chowder" else: "examplefuncsplayer")) + var doc = ReplayDoc(gameVersion: GameVersion, year: "bc20", + config: %*{"year": "bc20"}, seed: 9, seats: seats, events: events, + result: resultsJson(seats, @[], plan, epDeadline, 0.0, 0.0), plan: plan) + for slot in 0 .. 1: doc.names[slot] = "s" & $slot + let written = $doc.toJson() + checkEq("the abandoned episode is recorded as a deadline", + parseJson(written)["result"]["reason"].getStr(), "deadline") + checkEq("an abandoned game is DISCARDED, never scored half-played", + parseJson(written)["result"]["games"].len, 0) + checkEq("and the stop round is the one load-bearing record", + parseJson(written)["plan"]["abandon_after"][0].getInt(), stopAt) + + ## Re-derive it from the WRITTEN BYTES and compare frame by frame against + ## the chain the recorder was on — the abandoned game carries no + ## `GameHeader`, so this is the only thing that proves the two agree. + let deriver = newDeriver(parseReplay(written)) + var frames = 0 + while deriver.advance(): frames += 1 + checkEq("playback re-derives every recorded round", frames, stopAt) + checkEq("and stops exactly where the recorder stopped", + deriver.session.currentRound, stopAt) + checkEq("with the recorder's own hash chain at the stop round", + deriver.session.hashChainHex(), + aborted.roundChains[(stopAt - 1) * ChainHexLen ..< stopAt * ChainHexLen]) + reDerived.add("abandoned") block: - ## Every bc20 end reason is covered above. - for reason in ["hq_destroyed", "quantity", "quality", "broadcasts", - "highest_id", "coin_flip", "abandoned"]: - check("record -> re-derive covered " & reason, reason in seenReasons) + ## Every bc20 end reason is covered, and by the means named above. + for reason in ["hq_destroyed", "quantity", "abandoned"]: + check("record -> re-derive covered " & reason, reason in reDerived) + for reason in ["quality", "broadcasts", "highest_id", "coin_flip"]: + check("a ladder vector produced " & reason, reason in ladderVector) # --- the written bytes ------------------------------------------------------ block: From 392055106c35591b28cba7991cf05d0fd8669600 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:57 -0700 Subject: [PATCH 09/15] r1-F9: `opening` and `rush_trigger` gate on the note's own statistic Both knobs were gated on the same proxy -- "in one more of the four games a friendly unit stood Chebyshev <= 1 from the enemy HQ" -- where the design note's table asks for a round delta (`opening`: the first enemy-half unit arrives >= 200 rounds earlier) and a deadline (`rush_trigger`: adjacent to the enemy HQ by round 350). Neither substitution was declared, unlike `net_gun_ring`'s. Both statistics turn out to be measurable, so they are measured rather than declared. `runSet` now records, per game, the round a friendly unit first stood closer to the enemy HQ than to its own (the round cap standing in for "never crossed") and whether one stood adjacent to the enemy HQ by round 350. * `opening` keeps the arrival counter and adds the note's statistic: turtle -> rush crosses into the enemy half 233 rounds earlier per game (1811 -> 878 summed over the four games), gated at 100 a game -- the same half-the-measured-delta rule the other nine thresholds follow. * `rush_trigger` keeps the arrival counter and adds the note's clause verbatim: 0 of 4 games reach the enemy HQ by round 350 at `0`, 1 of 4 at `220`. 16 checks, up from 14; no gate was removed or loosened. The header table records both new measurements and, with them, corrects its "Measured at GameVersion GV04" line to GV05 (also r1-F11). --- tests/test_bc20_knobs.nim | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/tests/test_bc20_knobs.nim b/tests/test_bc20_knobs.nim index ed4729f..1955ac8 100644 --- a/tests/test_bc20_knobs.nim +++ b/tests/test_bc20_knobs.nim @@ -14,10 +14,12 @@ ## ## The thresholds live in ONE table so tuning is a one-line change, and each is ## set at roughly half the measured delta so a real regression trips it and -## ordinary drift does not. Measured at GameVersion GV04: +## ordinary drift does not. Measured at GameVersion GV05: ## ## knob low -> high measured gate ## opening turtle -> rush adj 0 -> 2 >= 1 +## enemy half -100 +## 1811 -> 878 /game ## terraform_start_round 900 -> 100 dirt 761->6286 +150 % ## lattice_radius 3 -> 10 raised 163->380 +60 ## landscaper_count_curve lean -> swarm land 14 -> 43 +50 % @@ -27,9 +29,22 @@ ## drone_role carry -> harass drops 0 -> 20 +5 ## net_gun_ring 0 -> 6 guns 0 -> 15 +4 ## rush_trigger 0 -> 220 adj 0 -> 1 >= 1 +## by round 350 0 -> >=1 +## 0 -> 1 ## wall_hq_round 0 -> 250 drowned 4 -> 0 ## ring 0 -> 32 +6 ## +## `opening` and `rush_trigger` each carry TWO gates: the +1-game arrival +## counter, and the design note's own statistic. The note asks that +## `opening`'s first enemy-half unit arrive "≥ 200 rounds earlier"; the +## measured delta is 233 rounds a game (1811 → 878 summed over the four +## games), and the gate is set at 100 a game — the same half-the-measured-delta +## rule every other threshold here follows, on the note's own statistic rather +## than on a proxy. `rush_trigger`'s second gate is the note's clause verbatim: +## a friendly unit adjacent to the enemy HQ BY ROUND 350, which happens in no +## game at 0 and in one of four at 220. The games run 500 rounds so the +## counter above stays measurable. +## ## DECLARED DEVIATION from the design note's table: `net_gun_ring` is gated on ## net guns BUILT only, not additionally on enemy drones shot down. The HQ has ## a built-in net gun and shoots on every turn it is ready, so `net_gun_kills` @@ -46,11 +61,20 @@ import battlecode/years/bc20/chassis/kit const Maps = ["WateredDown", "ALandDivided"] Chassis = [ckBowlOfChowder, ckBowlOfChowder] + Games = Maps.len * 2 ## two maps under both side assignments type Measured = object landscapers, miners, drones, netGuns, vaporators: int dirt, waterDrops, hqDrowned, ringAboveFive, raisedNearHq: int reachedEnemyHq: int + enemyHalfRoundSum: int + ## Summed over the four games: the round a friendly unit FIRST stood + ## closer to the enemy HQ than to its own, or the round cap when none ever + ## did. Lower is earlier, and the sentinel keeps the sum defined for a + ## doctrine that never crosses. + adjacentBy350: int + ## Games in which a friendly unit stood Chebyshev <= 1 from the enemy HQ + ## by round 350 — the note's own `rush_trigger` statistic. pollutionAt1000: int proc runSet(knobs: string, rounds: int): Measured = @@ -66,14 +90,19 @@ proc runSet(knobs: string, rounds: int): Measured = let hqs = spec.hqLocations() let mine = hqs[team] let theirs = hqs[1 - team] - var reached = false + var reachedAt = 0 + var crossedAt = 0 for round in 1 .. rounds: runRound(w, sides, Chassis) - if not reached: + if reachedAt == 0 or crossedAt == 0: for id, r in w.robotsById: - if r.team == Team(team) and chebyshev(r.loc, theirs) <= 1: - reached = true - break + if r.team != Team(team): continue + if reachedAt == 0 and chebyshev(r.loc, theirs) <= 1: + reachedAt = round + if crossedAt == 0 and + chebyshev(r.loc, theirs) < chebyshev(r.loc, mine): + crossedAt = round + if reachedAt > 0 and crossedAt > 0: break if round == 1000: result.pollutionAt1000 += w.globalPollution if not w.running: break @@ -85,7 +114,9 @@ proc runSet(knobs: string, rounds: int): Measured = result.dirt += w.stats.dirtMoved[team] result.waterDrops += w.stats.droneWaterDrops[team] if w.stats.destroyedHq[team]: result.hqDrowned += 1 - if reached: result.reachedEnemyHq += 1 + if reachedAt > 0: result.reachedEnemyHq += 1 + if reachedAt in 1 .. 350: result.adjacentBy350 += 1 + result.enemyHalfRoundSum += (if crossedAt > 0: crossedAt else: rounds) for l in w.ringTiles(mine): if w.getDirt(l) >= 5: result.ringAboveFive += 1 for x in max(0, mine.x - 10) .. min(w.width - 1, mine.x + 10): @@ -104,6 +135,15 @@ block: check("opening turtle -> rush puts a unit next to the enemy HQ (" & $low.reachedEnemyHq & " -> " & $high.reachedEnemyHq & ")", high.reachedEnemyHq >= low.reachedEnemyHq + 1) + ## The note's own statistic: the first friendly unit on the ENEMY HALF + ## arrives earlier. Summed over the four games, with the round cap standing + ## in for "never crossed", so the sum is defined for a turtle that stays + ## home. + check("and its first unit crosses into the enemy half " & + $((low.enemyHalfRoundSum - high.enemyHalfRoundSum) div Games) & + " rounds earlier per game, gate 100 (" & $low.enemyHalfRoundSum & + " -> " & $high.enemyHalfRoundSum & " summed over " & $Games & ")", + high.enemyHalfRoundSum <= low.enemyHalfRoundSum - 100 * Games) # --- terraform_start_round -------------------------------------------------- block: @@ -168,6 +208,13 @@ block: check("rush_trigger 0 -> 220 puts a unit next to the enemy HQ by round " & "500 (" & $low.reachedEnemyHq & " -> " & $high.reachedEnemyHq & ")", high.reachedEnemyHq >= low.reachedEnemyHq + 1) + ## The note's own statistic: adjacent to the enemy HQ BY ROUND 350. The + ## games run to 500 so the counter above is measurable too, but this gate + ## reads only the arrivals inside the note's window. + check("and it does so BY ROUND 350, which never happens at 0 (" & + $low.adjacentBy350 & " -> " & $high.adjacentBy350 & " of " & $Games & + " games)", + low.adjacentBy350 == 0 and high.adjacentBy350 >= 1) # --- wall_hq_round ---------------------------------------------------------- block: From 83f5cd5b6a0b87ac65de76d0b40cc001363b5896 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:52:59 -0700 Subject: [PATCH 10/15] r1-F10: declare move-into-water as fatal, with the engine citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design note's rule 6.1 reads like a legality check -- "a drone may enter a flooded tile; nothing else may" -- and the port does something else: `canMove` never tests flooding and `move` destroys a non-flying mover. That is the engine, and it was asserted only by a comment. docs/RULES-BC20.md §Divergences gains item 17 with the upstream citation at the pinned commit 7618f6b: `RobotControllerImpl.assertCanMove` (:344-365) tests type, adjacency, bounds, occupancy, MAX_DIRT_DIFFERENCE and readiness and never mentions flooding; `move` (:382-391) tests it afterwards and calls `disintegrate()`, which throws RobotDeathException (:937-939); `GameWorld.updateRobot` (:190-191) then destroys the robot. `world.move`'s comment carries the same citation. tests/test_bc20_flood.nim now pins the path the oracle cannot reach: the move is legal, the miner dies, neither tile holds it, and a drone flies onto the same tile and lives. 32 checks, up from 26. --- docs/RULES-BC20.md | 16 ++++++++++++++++ src/battlecode/years/bc20/world.nim | 19 ++++++++++++++++--- tests/test_bc20_flood.nim | 24 ++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/RULES-BC20.md b/docs/RULES-BC20.md index 7a68c38..cc1dea2 100644 --- a/docs/RULES-BC20.md +++ b/docs/RULES-BC20.md @@ -206,6 +206,22 @@ Every one of these is deliberate and is the reason the parity oracle compares * **dirt dropped on a building buries it** (rule 6.6), and the HQ ring is precisely what the landscapers raise. A net gun on the ring is buried by its own team's wall, so the ring is the one place it may not stand. +17. **A non-flying unit that moves into water is DESTROYED, not refused.** The + rule as usually stated — "a drone may enter a flooded tile; nothing else + may" — reads like a legality check, and it is not one. In + `engine/src/main/battlecode/world/RobotControllerImpl.java:382-401` at the + pinned commit `7618f6b`, `move` calls `assertCanMove`, which tests type, + adjacency, the map bounds, occupancy, `MAX_DIRT_DIFFERENCE` and readiness + and **never mentions flooding**; the flood test comes afterwards and calls + `disintegrate()`, which throws `RobotDeathException` (`:937-939`) and ends + the turn. `GameWorld.updateRobot:190-191` then destroys the robot. So the + move is legal, the mover dies, and the tile stays empty. `world.canMove` + reproduces the assert exactly (no flood test) and `world.move` reproduces + the disintegration; the port destroys the mover at that point rather than + at the end of its own turn, which no other body can observe because + nothing acts in between. Neither chassis ever plans such a move — + `pathing.nim` excludes flooded and about-to-flood tiles — so this is the + rule for a doctrine that would. ## Where the archetypes come from diff --git a/src/battlecode/years/bc20/world.nim b/src/battlecode/years/bc20/world.nim index f69cf72..e98a98f 100644 --- a/src/battlecode/years/bc20/world.nim +++ b/src/battlecode/years/bc20/world.nim @@ -571,9 +571,22 @@ proc movePickedUpUnit(w: World, drone: Robot, center: Loc) = w.robotsById[drone.heldId].loc = center proc move*(w: World, r: Robot, d: Dir) = - ## `RobotControllerImpl.move`. The engine checks the destination for flooding - ## AFTER the legality assert and disintegrates the mover — a non-flying unit - ## that walks into water dies instead of moving. + ## `RobotControllerImpl.move` + ## (`engine/src/main/battlecode/world/RobotControllerImpl.java:382-401` at + ## the pinned commit `7618f6b`): `assertCanMove` does NOT test flooding, and + ## the destination is checked for it AFTERWARDS — + ## + ## assertCanMove(center); + ## // now check if the location is flooded and the robot can't fly + ## if (gameWorld.isFlooded(center) && !getType().canFly()) { + ## disintegrate(); // throws RobotDeathException (:937-939) + ## } + ## + ## so a non-flying unit that walks into water dies where it stands and never + ## occupies the tile. The engine destroys it at the end of that same turn + ## (`GameWorld.updateRobot:190-191`); nothing else acts in between, so + ## destroying it here is the same match. §Divergences item 17 in + ## `docs/RULES-BC20.md`. let center = r.loc + d if not w.canMove(r, d): return if w.isFlooded(center) and not r.kind.canFly(): diff --git a/tests/test_bc20_flood.nim b/tests/test_bc20_flood.nim index 0fd0261..bf7d0fb 100644 --- a/tests/test_bc20_flood.nim +++ b/tests/test_bc20_flood.nim @@ -110,4 +110,28 @@ block: check("the miner dropped into water died", minerId notin w.robotsById) check("and the drone is still flying", droneId in w.robotsById) +block: + ## Moving INTO water is not refused, it is fatal. `assertCanMove` never + ## tests flooding (`RobotControllerImpl.java:344-365` at 7618f6b); `move` + ## tests it afterwards and disintegrates the mover (`:382-391`). So + ## `canMove` says yes, the miner dies where it stands, and the water tile + ## stays empty. A drone flies in and lives. §Divergences item 17. + var w = newWorld(flat(7, 7, 0, @[3 + 7 * 3]), 1500) + let minerId = w.spawnRobot(rtMiner, loc(3, 2), teamA) + let miner = w.robotsById[minerId] + miner.cooldownTurns = 0 + check("the move is LEGAL — flooding is not part of the assert", + w.canMove(miner, dNorth)) + w.move(miner, dNorth) + check("but the miner is destroyed", minerId notin w.robotsById) + check("and never occupies the water tile", w.getRobot(loc(3, 3)) == nil) + check("nor stands on its old tile", w.getRobot(loc(3, 2)) == nil) + + let droneId = w.spawnRobot(rtDeliveryDrone, loc(3, 2), teamA) + let drone = w.robotsById[droneId] + drone.cooldownTurns = 0 + w.move(drone, dNorth) + check("a drone flies onto the same tile and lives", droneId in w.robotsById) + checkEq("and is standing on it", drone.loc, loc(3, 3)) + finish("test_bc20_flood") From e18ad4f37f375ed2dfe83f3da1873d6c777c7809 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:53:03 -0700 Subject: [PATCH 11/15] r1-F11: the stale in-tree pointers, and the version check nothing ran Four pointers named nothing: * `years/bc20/constants.nim:5` credited `tests/test_bc20_constants.nim` with regenerating and byte-diffing it; no such file exists. The gate is the `test` job's `gen_year_constants.py --year bc20 --check`. Fixed in the GENERATOR's header template and the file regenerated against the pinned sources, so `--check` still passes byte for byte (verified locally). * `tests/test_bc20_maps.nim:2` credited `tools/ci/check_bc20_maps.sh`; the byte-diff is `tools/convert_maps_bc20.py --check` in the same job. * `tools/ci/check_gameversion.sh`'s own comment still quoted GV04. * the script was wired into no workflow at all. It is now a step in the `test` job on every non-main ref, against a depth-1 fetch of origin/main. Same number + same rule headline exits 0, which is every PR that does not touch the rules -- including this one (verified locally: base GV05, head GV05, "no rule change claimed"). The fourth stale pointer, test_bc20_knobs.nim's "Measured at GameVersion GV04", was corrected to GV05 in the r1-F9 commit, which rewrote that table. --- .github/workflows/ci.yml | 14 ++++++++++++++ src/battlecode/years/bc20/constants.nim | 9 +++++---- tests/test_bc20_maps.nim | 5 +++-- tools/ci/check_gameversion.sh | 2 +- tools/gen_year_constants.py | 9 +++++---- 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4a4781..b7c5c42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,20 @@ jobs: done exit "${fail}" + # A GameVersion is claimed across BRANCHES, and nothing else in the build + # enforces that: two branches can each be current with main and still + # pick the same next number, and the collision lands silently -- the + # replay still LOADS and then re-simulates wrong. tools/ci/ + # check_gameversion.sh has always been in the tree; until now nothing + # ran it. Same number + same rule headline (the overwhelmingly common + # case, every PR that does not touch the rules) is fine and exits 0. + - name: The GameVersion does not collide with main's + if: github.ref != 'refs/heads/main' + run: | + set -euo pipefail + git fetch --depth=1 origin main + tools/ci/check_gameversion.sh FETCH_HEAD HEAD + # The manifest is validated by the CLI THAT WILL PUBLISH IT, not only # by tests/test_manifest.nim's own reading of the schema. `coworld # build` runs `_load_template_manifest` -> `validate_upload_manifest` diff --git a/src/battlecode/years/bc20/constants.nim b/src/battlecode/years/bc20/constants.nim index 542b1f7..157150f 100644 --- a/src/battlecode/years/bc20/constants.nim +++ b/src/battlecode/years/bc20/constants.nim @@ -2,10 +2,11 @@ ## ## Source: github.com/battlecode/battlecode20 at commit `7618f6be7d12da39f2e6e25801e578f1fecfbd86`, ## files `common/GameConstants.java` and `common/RobotType.java`, read by -## `tools/gen_year_constants.py --year bc20`. `tests/test_bc20_constants.nim` -## regenerates this file and byte-diffs it, so an edit here fails the build -## instead of quietly changing the rules under a `GameVersion` that no -## longer describes them. +## `tools/gen_year_constants.py --year bc20`. The `test` job of +## `.github/workflows/ci.yml` re-runs that generator with `--check`, +## which byte-diffs this file, so an edit here fails the build instead +## of quietly changing the rules under a `GameVersion` that no longer +## describes them. ## ## The two derived functions `getWaterLevel`, `getSensorRadiusPollutionCoefficient` ## and `getCooldownPollutionCoefficient` are NOT constants and live in diff --git a/tests/test_bc20_maps.nim b/tests/test_bc20_maps.nim index 0ac2fc3..5902890 100644 --- a/tests/test_bc20_maps.nim +++ b/tests/test_bc20_maps.nim @@ -1,5 +1,6 @@ -## The converted bc20 maps: every one re-converts identically (checked by -## `tools/ci/check_bc20_maps.sh` in CI, and by shape here), the sizes and +## The converted bc20 maps: every one re-converts identically (byte-diffed by +## `tools/convert_maps_bc20.py --check` in the `test` job of +## `.github/workflows/ci.yml`, and checked for shape here), the sizes and ## symmetries match the pinned table, the sim's own per-spawn detector agrees ## with the converter, and every array is `width x height`. diff --git a/tools/ci/check_gameversion.sh b/tools/ci/check_gameversion.sh index 9e23b43..62feddf 100755 --- a/tools/ci/check_gameversion.sh +++ b/tools/ci/check_gameversion.sh @@ -33,7 +33,7 @@ line() { # The GameVersion declaration line from one ref, or empty if unreadable. git show "$1:$CONST_FILE" 2>/dev/null | grep -m1 'GameVersion\* =' } -# The constant is `GameVersion* = "GV04"`, so the digits sit behind a `GV` +# The constant is `GameVersion* = "GV05"`, so the digits sit behind a `GV` # prefix; `grep -o '"[0-9]*"'` matched nothing and every invocation died on # "could not read GameVersion" instead of comparing anything. ver() { line "$1" | grep -o '"GV[0-9]*"' | tr -d '"GV'; } diff --git a/tools/gen_year_constants.py b/tools/gen_year_constants.py index 2e83acb..0611790 100644 --- a/tools/gen_year_constants.py +++ b/tools/gen_year_constants.py @@ -238,10 +238,11 @@ def render_bc20(engine: pathlib.Path) -> str: add("##") add(f"## Source: github.com/battlecode/battlecode20 at commit `{BC20_COMMIT}`,") add("## files `common/GameConstants.java` and `common/RobotType.java`, read by") - add("## `tools/gen_year_constants.py --year bc20`. `tests/test_bc20_constants.nim`") - add("## regenerates this file and byte-diffs it, so an edit here fails the build") - add("## instead of quietly changing the rules under a `GameVersion` that no") - add("## longer describes them.") + add("## `tools/gen_year_constants.py --year bc20`. The `test` job of") + add("## `.github/workflows/ci.yml` re-runs that generator with `--check`,") + add("## which byte-diffs this file, so an edit here fails the build instead") + add("## of quietly changing the rules under a `GameVersion` that no longer") + add("## describes them.") add("##") add("## The two derived functions `getWaterLevel`, `getSensorRadiusPollutionCoefficient`") add("## and `getCooldownPollutionCoefficient` are NOT constants and live in") From e9a044b6c5e5a1f570540f54a3acedb26b8e904b Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:53:06 -0700 Subject: [PATCH 12/15] r1-F12: delete the dead SeatPolicy.baseline field Nothing in src/ read `.baseline` after the year-aware baseline resolution landed: the only write was `app.policy[slot].baseline = blAwu` at server init, and every consumer goes through `baselineForSeat(year, seat)`, which resolves the raw `PLAYER_SCRIPTED` string per year. A field holding a bc26 baseline on a bc20 seat is exactly the confusion the year-aware change removed. Field, its one write and its one mention in a test constructor deleted; the `scripted` field's doc comment now says where the resolution happens and why it is not done here. No behaviour change; test_sheet, test_seats and test_bc20_sheet unchanged in count. --- src/battlecode/decide.nim | 6 +++--- src/battlecode/server.nim | 1 - tests/test_sheet.nim | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/battlecode/decide.nim b/src/battlecode/decide.nim index 62d02b7..93b0d9d 100644 --- a/src/battlecode/decide.nim +++ b/src/battlecode/decide.nim @@ -24,9 +24,9 @@ type prompt*: string scripted*: string ## The raw `PLAYER_SCRIPTED` value. Resolved to a `Baseline` PER YEAR at - ## episode time, because `bowl-of-chowder` means nothing to bc26 and - ## `awu` means nothing to bc20. - baseline*: Baseline + ## episode time by `baselineForSeat`, because `bowl-of-chowder` means + ## nothing to bc26 and `awu` means nothing to bc20. Storing a parsed + ## `Baseline` on the seat would be resolving it before the year is known. label*: string registered*: bool diff --git a/src/battlecode/server.nim b/src/battlecode/server.nim index 1c36052..6d351e8 100644 --- a/src/battlecode/server.nim +++ b/src/battlecode/server.nim @@ -42,7 +42,6 @@ proc initAppState() = app.phase = "waiting for seats" app.resultsDoc = "{}" for slot in 0 .. 1: - app.policy[slot].baseline = blAwu app.policy[slot].label = "awu" proc globalJson(): string {.gcsafe.} diff --git a/tests/test_sheet.nim b/tests/test_sheet.nim index b5fca79..6456503 100644 --- a/tests/test_sheet.nim +++ b/tests/test_sheet.nim @@ -271,7 +271,7 @@ block: var seats: array[2, SeatPolicy] for slot in 0 .. 1: seats[slot] = SeatPolicy(isLlm: true, prompt: "doctrine, please", - baseline: blAwu, registered: true) + registered: true) let decision = decide(config, plan, seats) delEnv("AWS_ENDPOINT_URL_BEDROCK_RUNTIME") delEnv("AWS_BEARER_TOKEN_BEDROCK") From c7e2e5f2b23e9d589c41db0ac41d4cb7af3bcca8 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:53:09 -0700 Subject: [PATCH 13/15] r1-F13: commit the bc20 fixture replay the note names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §Tests item 17 asks the wasm module to answer `bc_load_replay`/`bc_frame` on a committed bc20 fixture replay; `tests/fixtures/` held only `java_random_vectors.json`, and the smoke ran against the replay docker-smoke produced in the same run. It earns its keep: 5 881 bytes, 119 rounds, byte-identical on a re-run (the world RNG comes from the map's own randomSeed), and it is a REAL recording written by the same `ReplayDoc.toJson` the server writes. * `tools/gen_bc20_fixture_replay.nim` records it -- bowl-of-chowder vs examplefuncsplayer on maptestsmall, seed 3, 120 rounds -- and refuses to write a recording that does not re-derive. * `tests/fixtures/replay-bc20.json` is that recording. * `tests/test_bc20_replay.nim` proves the committed bytes still parse, carry a `GameVersion` in `ReplayCompatibleGameVersions`, and re-derive round for round under the current sim. A rule change turns this red, with the re-record command in the assertion's own message. 67 checks, up from 59. * `ci.yml` runs `wasm_replay_smoke.cjs` against it as a third target, so the wasm32 re-derivation is driven by committed bytes and not only by bytes the same run produced. --- .github/workflows/ci.yml | 6 +++ tests/fixtures/replay-bc20.json | 1 + tests/test_bc20_replay.nim | 34 +++++++++++++ tools/gen_bc20_fixture_replay.nim | 81 +++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 tests/fixtures/replay-bc20.json create mode 100644 tools/gen_bc20_fixture_replay.nim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7c5c42..bde370d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -868,6 +868,12 @@ jobs: "${{ github.workspace }}/dist/smoke/replay.json" node tools/wasm_replay_smoke.cjs dist/static-replay-viewer \ "${{ github.workspace }}/dist/smoke/replay-bc20.json" + # And against the COMMITTED bc20 fixture (design note §Tests item + # 17): bytes recorded at a known GameVersion, not bytes this run + # produced. tests/test_bc20_replay.nim proves the same file + # re-derives natively, so a divergence here is wasm32's alone. + node tools/wasm_replay_smoke.cjs dist/static-replay-viewer \ + "${{ github.workspace }}/tests/fixtures/replay-bc20.json" # The text the CI replay never carries. Every replay CI produces is # SCRIPTED, so its notes and motto are the short strings baselines.nim diff --git a/tests/fixtures/replay-bc20.json b/tests/fixtures/replay-bc20.json new file mode 100644 index 0000000..4aaecf6 --- /dev/null +++ b/tests/fixtures/replay-bc20.json @@ -0,0 +1 @@ +{"format":"cogame-battlecode-replay","version":1,"protocol":"cogame.battlecode.v1","game_version":"GV05","year":"bc20","config":{"seed":3,"year":"bc20","max_rounds":120},"seed":3,"aliases":["Clan Ash","Clan Basil"],"names":["seat0","seat1"],"seats":[{"slot":0,"alias":"Clan Ash","name":"seat0","policy":"scripted","chassis":"bowl-of-chowder","sheet":{"opening":"passive_lattice","terraform_start_round":300,"lattice_radius":6,"landscaper_count_curve":"steady","miner_count_curve":"steady","vaporator_budget":2,"drone_role":"harass","net_gun_ring":2,"rush_trigger":0,"wall_hq_round":250},"sheet_submitted":"{\"opening\":\"passive_lattice\",\"terraform_start_round\":300,\"lattice_radius\":6,\"landscaper_count_curve\":\"steady\",\"miner_count_curve\":\"steady\",\"vaporator_budget\":2,\"drone_role\":\"harass\",\"net_gun_ring\":2,\"rush_trigger\":0,\"wall_hq_round\":250}","sheet_defaults_applied":[],"sheet_unknown_fields":[],"notes":"default bowl-of-chowder doctrine","motto":"Soup first.","decision_ms":0,"prompt":null,"fallback":null,"fallback_detail":null},{"slot":1,"alias":"Clan Basil","name":"seat1","policy":"scripted","chassis":"examplefuncsplayer","sheet":{"opening":"passive_lattice","terraform_start_round":300,"lattice_radius":6,"landscaper_count_curve":"steady","miner_count_curve":"steady","vaporator_budget":2,"drone_role":"harass","net_gun_ring":2,"rush_trigger":0,"wall_hq_round":250},"sheet_submitted":"{\"opening\":\"passive_lattice\",\"terraform_start_round\":300,\"lattice_radius\":6,\"landscaper_count_curve\":\"steady\",\"miner_count_curve\":\"steady\",\"vaporator_budget\":2,\"drone_role\":\"harass\",\"net_gun_ring\":2,\"rush_trigger\":0,\"wall_hq_round\":250}","sheet_defaults_applied":[],"sheet_unknown_fields":[],"notes":"scaffold baseline (2020)","motto":"Forward.","decision_ms":0,"prompt":null,"fallback":null,"fallback_detail":null}],"prompt_preamble":"","games":[{"index":0,"map":"maptestsmall","map_json_sha256":"33706507a068e317ea5b973b1f81fd289d4e336030538d991ffb0b0bd1de0d98","sides":["A","B"],"side_a_slot":0,"rounds":119,"hash_chain_sha256":"26816B2671607AD0","hash_chain_rounds":"E4F84B5673699E41C7552691E6487B42ACF702561FA8A06A7CD8D1C6A1AC1CCB91D82AADFC016933C0C10A2C6AA7B77D7B0B542A731AC44E671E0720C43C4D87601AE5898BF117D777DE797DC0B4A215CC93D610B00353508873D08368390B375FEF88DC7AD2EB90FBADC12C42911A7F1C0C4EB9ED120ED31B96CD89AC36BD4BA5BD5584D3ED59AC748BC916FA4A8E177B9F5EA5644C9B7FC4CF68779489A2F7B9CA66474FEDB34B744F7DF8C0639B9D3ABDA2807345D4315C87951CD3AF2FF5286F42778B2862207AD703261CB45C8061D0BA735D2CC62C4F467523A834BA3FA8D13A66842B5F6A4F5AB35AF63B45DEBF6DE2B144F3CAAD88341FCF9AF63BE22C712E15BBB1FADD9B730E46FD70797ACC281E8A2E772CC0E1B535A93773853FDAAF7A1BBDE2CF5C81D6726F9ED570262A451DAB171E8CB61ED79D6F13ACA21017688388535E8214114212A1D8317C28B2031DF063E8B07A7FCD3DC7CF25484EFB2EDEDF6670106583E22C29DFC157E1CCD0D3C6EEF3512186BE3BBBFF81B7CFBC3CC2AE96F8992FEED832748EEAF42ECA78869A6A63FC7CDE8DC56A5A848B37CF25E52D94974E16D3683ADF1B1BE306F2AD72CD46FCCA41C8F4AF4318A00FA5F0E71FC0E96E609CD6BDE7C15E174B19B95268BA6A5B5E75732DEC419C4343D9433CCF5EBA36283F13F0D85B35331F758C04F37C33E54F9688B57D442B15B56CD14FBDA3531DECE56E751239A4D0E7A9B8827A5953666BBD69E4982C6C3643A001E47793845E91BB5FDB91A0C33C504DF2773A3B4B366610E96DF9C8899919E379A303A50993FA033C8D6F17061CF2E4B37352F208B509882BCC195229FC776E743E41D5A8C4A0C40626B1D4FF8DAF66B01A10DE9A74E7B241580105AA77C8E0EBB7EC6EB98BCC5881DBB5F04E768383A257287234C6B0192CF3294958061B7ED6BFF6E4E99F5B5908DA4B1D5478FD966B2481E331AE94D38AE8E525C691F945148B0C8F30FFD0BBF8E71879CACEE2DD76426E6319692ED10E5A8905B45148CE6F2644004F7D245CA7B3D725E6F72B9F57A84EBA8E44C3B463943CC930CBA1FA4F0FEF228C25B44918CD201101E1FAA59F0B2AA79FA412ED5668284FAEAD8BD13C36673F60F8550CB7FABE37C6B76811312F2C31B93191C8743F6892151338FE1B2D8A6FE23DF5331C212610464C11CB39A4B968A6EF5A6015D8986E4F25DBFA9295B7E3866B2F1092911895182F7C6447CFB2E3B9A616C3C3D299F0901AD9B08736FDEBC18CF1C5900766A3CC3AC940D2E74EE42938C5B43F613174A954C50C0BBD867E750D7A707AE1A13E9CE834B926816B2671607AD0"}],"plan":{"maps":["maptestsmall"],"side_a_slots":[0],"abandon_after":[-1],"max_rounds":120},"events":[{"kind":"game_start","game":0,"round":0,"map":"maptestsmall","width":32,"height":32,"sides":["Clan Ash","Clan Basil"]},{"kind":"first_build","game":0,"round":1,"alias":"Clan Ash","unit":"miner"},{"kind":"first_build","game":0,"round":34,"alias":"Clan Ash","unit":"design_school"},{"kind":"first_build","game":0,"round":55,"alias":"Clan Ash","unit":"landscaper"},{"kind":"first_build","game":0,"round":95,"alias":"Clan Ash","unit":"refinery"},{"kind":"wall_closed","game":0,"round":104,"alias":"Clan Ash","min_ring_elevation":4},{"kind":"first_build","game":0,"round":107,"alias":"Clan Ash","unit":"net_gun"},{"kind":"game_end","game":0,"round":119,"winner_alias":"Clan Ash","winner_slot":0,"end_reason":"quantity","points":[63,36]}],"result":{"names":["seat0","seat1"],"aliases":["Clan Ash","Clan Basil"],"scores":[163.0,36.0],"wins":[1,0],"points":[[63],[36]],"games":[{"map":"maptestsmall","side":["A","B"],"rounds_played":119,"winner":0,"end_reason":"quantity","hq_alive":[true,true],"hq_lost_round":[-1,-1],"hq_lost_cause":["none","none"],"soup_mined":[2370,21],"soup_refined":[1640,0],"net_worth":[1953,249],"units_alive":[15,4],"units_built":[14,4],"miners_built":[6,4],"landscapers_built":[4,0],"drones_built":[0,0],"vaporators_built":[0,0],"net_guns_built":[2,0],"dirt_moved":[66,0],"drone_pickups":[0,0],"drone_water_drops":[0,0],"net_gun_kills":[0,0],"transactions_sent":[6,0],"transactions_minted":[6,0],"blockchain_soup_spent":[6,0],"global_pollution_peak":82,"flooded_tiles_end":36,"water_level_end":0.4192451238632202}],"seed":3,"year":"bc20","policy_kind":["scripted","scripted"],"sheet_defaults_applied":[[],[]],"fallbacks":[0,0],"decision_ms":[0,0],"sim_seconds":0.0,"reason":"complete","wall_clock_seconds":0.0,"game_version":"GV05"}} \ No newline at end of file diff --git a/tests/test_bc20_replay.nim b/tests/test_bc20_replay.nim index 165dc2d..3b48508 100644 --- a/tests/test_bc20_replay.nim +++ b/tests/test_bc20_replay.nim @@ -313,4 +313,38 @@ block: check("and the soup readout", chrome.hasKey("bc20_soup")) check("and the unit readout", chrome.hasKey("bc20_units")) +# --- the committed fixture -------------------------------------------------- +block: + ## `tests/fixtures/replay-bc20.json` is a REAL recording, committed + ## (§Tests item 17): the bytes `tools/wasm_replay_smoke.cjs` drives the + ## emitted wasm module against, independently of whatever `docker-smoke` + ## produced in the same run. Here it is proved natively: the committed bytes + ## still parse, still carry the year and a compatible `GameVersion`, and + ## still re-derive round for round under the CURRENT sim. + ## + ## When a rule changes this check goes red. That is the point — re-record + ## with `nim r --path:src tools/gen_bc20_fixture_replay.nim`, in the commit + ## that bumps the version. + const FixturePath = "tests/fixtures/replay-bc20.json" + check("the committed bc20 fixture replay exists", fileExists(FixturePath)) + let bytes = readFile(FixturePath) + check("and is valid UTF-8", validateUtf8(bytes) == -1) + let node = parseJson(bytes) + checkEq("and is a battlecode replay", node["format"].getStr(), + "cogame-battlecode-replay") + checkEq("of the bc20 year", node["year"].getStr(), "bc20") + check("at a GameVersion this build still loads", + node["game_version"].getStr() in ReplayCompatibleGameVersions) + let fixture = parseReplay(bytes) + let fixtureDeriver = newDeriver(fixture) + var fixtureFrames = 0 + while fixtureDeriver.advance(): fixtureFrames += 1 + check("the fixture is long enough for the wasm smoke's 50-frame floor", + fixtureFrames >= 50) + checkEq("it re-derives every recorded round", fixtureFrames, + fixture.games[0].rounds) + checkEq("with no divergence from the recorded chain — re-record with " & + "tools/gen_bc20_fixture_replay.nim if a rule changed", + fixtureDeriver.mismatchRound, -1) + finish("test_bc20_replay") diff --git a/tools/gen_bc20_fixture_replay.nim b/tools/gen_bc20_fixture_replay.nim new file mode 100644 index 0000000..241ada8 --- /dev/null +++ b/tools/gen_bc20_fixture_replay.nim @@ -0,0 +1,81 @@ +## Records `tests/fixtures/replay-bc20.json`, the committed bc20 fixture +## replay (design note §Tests item 17). +## +## nim r --path:src tools/gen_bc20_fixture_replay.nim [out.json] +## +## It is a real recording, not a hand-written document: one scripted +## `bowl-of-chowder` vs `examplefuncsplayer` game on `maptestsmall`, seed 3, +## capped at 120 rounds, written by the same `ReplayDoc.toJson` the server +## writes. Nothing about it is random — the world RNG comes from the map's own +## `randomSeed` — so re-running this produces the same bytes. +## +## The fixture exists so that the emitted wasm module can be driven against +## COMMITTED bytes (`tools/wasm_replay_smoke.cjs`) rather than only against +## the replay `docker-smoke` produced in the same run, and so that +## `tests/test_bc20_replay.nim` can prove a recording made at one +## `GameVersion` still re-derives. A rule change therefore turns that test +## red: re-record with this program, in the same commit that bumps the +## version. + +import std/[json, os] +import battlecode/[baselines, match, replay, results, sheet, sim_types] +import battlecode/years/dispatch + +const + Chassis = [ckBowlOfChowder, ckExamplefuncsplayer] + DefaultOut = "tests/fixtures/replay-bc20.json" + Map = "maptestsmall" + Seed = 3 + Rounds = 120 + +proc main() = + var config = defaultGameConfig() + config.year = "bc20" + config.pool = "small" + config.gamesPerMatch = 1 + config.maxRounds = Rounds + let doctrines = [baselineSheet("bc20", blBowlOfChowder), + baselineSheet("bc20", blExamplefuncsplayer)] + var plan = buildPlan(config, doctrines, Seed) + plan.chassis = Chassis + plan.maps = @[Map] + plan.sideAslots = @[0] + plan.abandonAfter = @[-1] + + var events: seq[MatchEvent] + let (games, reason) = playMatch(config, plan, events) + if games.len != 1: + quit("the fixture game did not finish: reason " & $reason) + + var seats: array[2, SeatReport] + for slot in 0 .. 1: + seats[slot] = SeatReport(name: "seat" & $slot, alias: aliasFor(slot), + policyKind: "scripted", sheet: doctrines[slot], + chassis: (if slot == 0: "bowl-of-chowder" else: "examplefuncsplayer")) + var doc = ReplayDoc(gameVersion: GameVersion, year: config.year, + config: %*{"seed": Seed, "year": config.year, "max_rounds": Rounds}, + seed: Seed, seats: seats, events: events, + result: resultsJson(seats, games, plan, reason, 0.0, 0.0), plan: plan) + for slot in 0 .. 1: doc.names[slot] = "seat" & $slot + for g in games: + doc.games.add(GameHeader(index: g.index, map: g.mapName, + mapSha: mapSha("bc20", g.mapName), sideAslot: g.sideAslot, + rounds: g.roundsPlayed, hashChain: g.hashChain, + roundChains: g.roundChains)) + + let text = $doc.toJson() + ## Refuse to write a recording that does not re-derive: a fixture the sim + ## cannot replay is worse than no fixture. + let deriver = newDeriver(parseReplay(text)) + var frames = 0 + while deriver.advance(): frames += 1 + if deriver.mismatchRound >= 0: + quit("the recording diverges from its own re-derivation at round " & + $deriver.mismatchRound) + + let outPath = if paramCount() >= 1: paramStr(1) else: DefaultOut + writeFile(outPath, text) + echo outPath, ": ", text.len, " bytes, ", frames, " rounds, ", + GameVersion, ", re-derives clean" + +main() From 3a897cf011cdc9c9966a535a252e9040c270cecb Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Thu, 3 Sep 2026 23:53:10 -0700 Subject: [PATCH 14/15] r1-F14: document first_build's real unit vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design note lists six values for `first_build.unit` and spells the drone `drone`. `Bc20UnitNames` is the ten `RobotKind` ordinals, so the emitted vocabulary is the engine's own type names — `delivery_drone`, and also `miner` and `refinery`, both of which the chassis genuinely builds. Documented rather than narrowed: the beat names the type that was built, and renaming or suppressing kinds would make the feed line disagree with the sim while hiding two builds a spectator can see happen. docs/REPLAY.md now lists the eight reachable values, says why the drone is `delivery_drone`, and points at §Divergences item 16 for the miner and the refinery. Every value draws as the `build` beat, which has CSS. --- docs/REPLAY.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/REPLAY.md b/docs/REPLAY.md index 76dda67..87d368c 100644 --- a/docs/REPLAY.md +++ b/docs/REPLAY.md @@ -161,7 +161,19 @@ Ten beat kinds, and **every one has CSS** in `client/replay_broadcast.html`. | `episode_end` | `reason` | — | `flood_stage` fires once per integer level reached, so a 1499-round game emits -at most six; `first_build` fires once per team per unit kind. `hq_buried` and -`hq_drowned` are derived from the recorded per-game statistics rather than -from a sim event, so the same two facts drive the endcard, the scrubber and -`results.games[]`. +at most six; `first_build` fires once per team per unit kind. + +`first_build.unit` is spelled with the engine's own `RobotType` names, which is +why the drone is `delivery_drone` and not `drone`: `miner`, `refinery`, +`vaporator`, `design_school`, `fulfillment_center`, `landscaper`, +`delivery_drone`, `net_gun`. (`hq` and `cow` complete the type list and are +never built by anyone, so they never appear.) The design note's shorter list — +six kinds, with `drone` for the drone — is the subset it expected the chassis +to put up; the chassis also builds miners and a refinery (§Divergences item 16 +in `docs/RULES-BC20.md`), and naming a beat after anything but the type that +was built would make the feed line disagree with the sim. Every one of them +draws as the `build` beat, which has CSS. + +`hq_buried` and `hq_drowned` are derived from the recorded per-game statistics +rather than from a sim event, so the same two facts drive the endcard, the +scrubber and `results.games[]`. From 4121a21e90aca3a56c3276b24e894af7a650b2c0 Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Fri, 4 Sep 2026 00:04:35 -0700 Subject: [PATCH 15/15] r1-F13 follow-up: the node wasm smoke was a silent no-op Found while verifying the fixture commit above: the "Smoke the emitted wasm module under node" step completes in 0.1 s with NO output, and has done so on every run including main's green 33841592052. It exits 0 having tested nothing, so committing a fixture for it bought nothing. Cause: the emitted glue opens with `var Module = typeof Module != "undefined" ? Module : {}`. Under `require()` that `var` is hoisted into the module scope and shadows `global.Module`, so `typeof Module` is "undefined" at that line and the glue builds its own empty Module. `onRuntimeInitialized` -- the whole body of this smoke -- belongs to our object and is never called; node then runs out of work and exits 0. Fix: run the same bytes through `vm.runInThisContext`, where the declaration sees the global object that already carries `Module` and adopts it, with `__dirname`/`__filename`/`require` supplied because the glue uses them under ENVIRONMENT_IS_NODE. Plus a 60 s watchdog that exits 1 with a message rather than exiting 0 in silence. Verified against the bundle CI built for this very run (downloaded from the `static-replay-viewer` artifact of run 33846271859) on all three targets: replay.json loaded=true GV05 first_packet=74797 frames=200 mismatch=-1 replay-bc20.json loaded=true GV05 first_packet=71039 frames=200 mismatch=-1 fixtures/replay-bc20.json loaded=true GV05 first_packet=69456 mismatch=-1 and as a negative control `results.json` now exits 1 with "not a cogame-battlecode-replay document" where before it also exited 0. --- tools/wasm_replay_smoke.cjs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tools/wasm_replay_smoke.cjs b/tools/wasm_replay_smoke.cjs index f0daa48..6495278 100644 --- a/tools/wasm_replay_smoke.cjs +++ b/tools/wasm_replay_smoke.cjs @@ -11,6 +11,7 @@ const fs = require('fs'); const path = require('path'); +const vm = require('vm'); const bundle = path.resolve(process.argv[2] || ''); const replayPath = path.resolve(process.argv[3] || ''); @@ -36,6 +37,7 @@ global.Module.printErr = (text) => console.error(' [wasm] ' + text); global.Module.onRuntimeInitialized = () => { const Module = global.Module; + console.log('runtime initialized; loading ' + path.basename(replayPath)); const bytes = fs.readFileSync(replayPath); const pointer = Module._malloc(bytes.length); Module.HEAPU8.set(bytes, pointer); @@ -111,4 +113,29 @@ global.Module.onRuntimeInitialized = () => { process.exit(0); }; -require(path.resolve(bundle, 'bc_replay.js')); +// LOAD IT IN GLOBAL SCOPE, NOT WITH require(). +// +// The glue opens with `var Module = typeof Module != "undefined" ? Module : {}`. +// Inside a CommonJS module that `var` is hoisted into the module scope and +// shadows `global.Module`, so `typeof Module` is "undefined" at that line and +// the glue silently builds its OWN empty Module: `locateFile` is not ours, +// `onRuntimeInitialized` above is never called, node runs out of work and +// exits 0 having tested NOTHING. That is what this file did from the day it +// was written — 0.1 s, no output, green. +// +// Run the same bytes with `vm.runInThisContext` and the declaration sees the +// global object, which already has `Module`, so the glue adopts it. The three +// bindings below are the ones a CommonJS wrapper would have supplied and the +// glue uses under ENVIRONMENT_IS_NODE. +global.__dirname = bundle; +global.__filename = path.join(bundle, 'bc_replay.js'); +global.require = require; +vm.runInThisContext(fs.readFileSync(global.__filename, 'utf8'), + { filename: global.__filename }); + +// And if it still never boots, say so instead of exiting 0. +setTimeout(() => { + console.error('the wasm runtime never initialized: ' + + 'Module.onRuntimeInitialized was not called within 60 s'); + process.exit(1); +}, 60000);