diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4a4781..bde370d 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` @@ -854,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/docs/PROTOCOL.md b/docs/PROTOCOL.md index a42c792..e4733a4 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -156,10 +156,26 @@ 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 +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 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[]`. diff --git a/docs/RULES-BC20.md b/docs/RULES-BC20.md index 51728ba..cc1dea2 100644 --- a/docs/RULES-BC20.md +++ b/docs/RULES-BC20.md @@ -182,6 +182,46 @@ 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`. +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. +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/decide.nim b/src/battlecode/decide.nim index 72f4d17..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 @@ -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, 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/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/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/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 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 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/src/battlecode/years/bc20/world.nim b/src/battlecode/years/bc20/world.nim index d458211..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(): @@ -737,7 +750,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 +768,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/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) 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_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 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") 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: 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/tests/test_bc20_replay.nim b/tests/test_bc20_replay.nim index 791ea1e..3b48508 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: - ## `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") + ## `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: + ## `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: @@ -245,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/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") 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/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 ' + 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() 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") 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);