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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions docs/REPLAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[]`.
40 changes: 40 additions & 0 deletions docs/RULES-BC20.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions src/battlecode/decide.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/battlecode/match.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion src/battlecode/server.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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.}
Expand Down
10 changes: 8 additions & 2 deletions src/battlecode/years/bc20/chassis/fulfillment.nim
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
4 changes: 4 additions & 0 deletions src/battlecode/years/bc20/chassis/miner.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/battlecode/years/bc20/chassis/signals.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions src/battlecode/years/bc20/constants.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions src/battlecode/years/bc20/world.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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) =
Expand All @@ -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 =
Expand Down
5 changes: 5 additions & 0 deletions src/battlecode/years/dispatch.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading