From c488388f4a74152ea79c12d8ac7e57acfa93751b Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Wed, 9 Sep 2026 03:25:09 +0000 Subject: [PATCH 01/16] feat(bc16): the Battlecode 2016 "Zombie Invasion" sim module The eighth year module and the first pre-2020 year, ported from battlecode/battlecode-server-2016 at 11a0b09f26a70da19f33a61ebec4ceaf6e161aa3 with a file:line citation on every rule. The official 2016 spec is lost (dead S3, dead battlecode.org, no Wayback copy) and this year's GameConstants has no SPEC_VERSION field at all, so the ENGINE SOURCE IS THE SPEC: GameWorld.java (1051 lines), InternalRobot.java (473), RobotControllerImpl.java (886), ZombieControlProvider.java (398), GameMap.java (874), RobotType.java (390), GameConstants.java (162), MapLocation/Direction/ZombieSpawnSchedule/ZombieCount/Team/IDGenerator/ GameMapIO were read whole and are cited in the module's own doc comments. The sim, in the note's own file layout: constants.nim GENERATED by tools/gen_year_constants.py --year bc16 (2016's GameConstants is an INTERFACE, so its fields carry no `public static final` and it needed its own regex), the whole twelve-row RobotType table with all seventeen constructor fields, and the outbreak ladder read out of the engine's own switch units.nim the pure per-unit arithmetic: four Teams, ten Directions with 2016's SOUTHWARD y axis, six symmetries, the eight derived predicates, the tabled (int) Math.sqrt(r2) for 0..10 000, the float64 rubble/move/guard/broadcast arithmetic, and directionTo's 2.414 fan computed in the engine's own doubles delays.nim the core/weapon pair and its four mutators, plus the two composite helpers whose OPPOSITE set/add pairing is the easiest way to break this year; decrementDelays is pinned to 1.0 (V1) and the engine's whole formula is kept beside it for the table and the tests health.nim the two INDEPENDENT infection counters, the viper tick's exactly-2.0 damage, the health cap, and deathConsequence -- the ordered decision a death makes (rubble or a zombie, never both) world.nim state, geometry, terrain, the INSERTION-ordered exec list with by-value removal, sensing in insertion order, the single changeHealthLevel mutation point with the mid-turn DESTROYED check, and every action of rule 3.2 zombies.nim the VERBATIM ZombieControlProvider: the den's three-step turn, spawnAllPossible's ring and its no-break "last non-zero type" priority, and processZombie's eight-step ladder with both RNG draw preconditions exact economy.nim max(0, 2 - 0.01 * robots) per team per round, A then B signals.nim the 1000-entry FIFO, the 5/20 per-turn counters, and the broadcast walk over ALL FOUR TEAMS with its two-counter charge rules.nim the four-step round loop (rounds numbered FROM ZERO), the four-rung round-2999 ladder on exact float64 differences, the float32-narrowed points formula and one game maps.nim the 22-map pool, the loader, the draw and the map cards knobs.nim the eleven-knob doctrine sheet, no `chassis` key (D1), with ABSENT-key defaulting counted (the envelope pin, item 2) chassis/ fourteen files: kit, econ, archon, combat, micro, turret, dens, neutral, rubble, infect, comms, bulwark, greenhorn and scenario16 data/maps/bc16/ carries the 22 converted maps, each with its BUILD-TIME per-den schedule split (D3) and computed symmetry (D4), so the runtime sim hashes nothing. tools/convert_maps_bc16.py reproduces Java 8's HashMap iteration order (h ^ (h >>> 16), bucket hash & (n-1), capacity 16, load factor 0.75, resize splitting each bucket in place) and that emulation was verified against a real java.util.HashMap on all 98 official rosters, 0 mismatches. All 98 maps parse; the two armageddon maps are REFUSED by flag (V4). data/atlas_bc16.* is cut from battlecode/battlecode-client-2016 (GPL-3.0, pinned 317e1f3f) by tools/build_sprite_atlas_bc16.py: 49 sprites, all twelve robot types at ALL FOUR Team palettes, so this is the first year in the repo whose art draws a NEUTRAL robot as itself. TheDuck314/battlecode2016 and bshimanuki/battlecode2016 carry NO LICENCE and were not cloned, not read, not copied, not vendored, not compiled and not translated; neither contributes a line. `bulwark` is written from the engine's own mechanics and from the three archetypes the run's idea text names. GameVersion GV10 -> GV11 with a prepend-only changelog entry, and ReplayCompatibleGameVersions EXTENDED to GV04..GV11 (never reset): this run makes no year-neutral behaviour change at all, so no recorded byte anywhere changes meaning. rng.nim gains ONE optional parameter -- IDGenerator's first block, which is 0 in 2016 (ids from 1) against the 10 000 floor every later year uses -- and every existing call site keeps the default. The year boundary is compiler-enforced: registry, dispatch (Session variant, every case arm, statsJson16 and the three name tables), sheet, baselines, decide (the bc16 preamble and observation), broadcast (beatsFor's bc16 arms and the five bc16 chrome records) and render (the six-step rubble heat ramp with hard breaks at 50 and 100, and the four-palette unit sprites) all carry their bc16 arm. Measured locally (release Nim, not CI): a 3000-round mirror on `river` runs in 2.2 s -- 0.74 ms/round at a peak of 45 and 41 robots -- well inside the note's 2-5 ms/round estimate and the 130 s perf gate. --- data/atlas_bc16.json | 1 + data/atlas_bc16.png | Bin 0 -> 27538 bytes data/maps/bc16/6147.json | 1 + data/maps/bc16/boxy.json | 1 + data/maps/bc16/caverns.json | 1 + data/maps/bc16/checkers.json | 1 + data/maps/bc16/closequarters.json | 1 + data/maps/bc16/collision.json | 1 + data/maps/bc16/desert.json | 1 + data/maps/bc16/frogger.json | 1 + data/maps/bc16/industrial.json | 1 + data/maps/bc16/lockdown.json | 1 + data/maps/bc16/prisons.json | 1 + data/maps/bc16/quadrants.json | 1 + data/maps/bc16/quarry.json | 1 + data/maps/bc16/river.json | 1 + data/maps/bc16/scouting.json | 1 + data/maps/bc16/space.json | 1 + data/maps/bc16/swamp.json | 1 + data/maps/bc16/turtle.json | 1 + data/maps/bc16/voluted.json | 1 + data/maps/bc16/vortex.json | 1 + data/maps/bc16/wormy.json | 1 + data/maps/bc16/zigzag.json | 1 + src/battlecode/baselines.nim | 24 + src/battlecode/broadcast.nim | 329 ++++- src/battlecode/decide.nim | 233 +++- src/battlecode/render.nim | 127 +- src/battlecode/rng.nim | 10 +- src/battlecode/sheet.nim | 19 +- src/battlecode/sim_types.nim | 36 +- src/battlecode/years/bc16/chassis/archon.nim | 172 +++ src/battlecode/years/bc16/chassis/bulwark.nim | 137 ++ src/battlecode/years/bc16/chassis/combat.nim | 69 ++ src/battlecode/years/bc16/chassis/comms.nim | 89 ++ src/battlecode/years/bc16/chassis/dens.nim | 70 ++ src/battlecode/years/bc16/chassis/econ.nim | 116 ++ .../years/bc16/chassis/greenhorn.nim | 72 ++ src/battlecode/years/bc16/chassis/infect.nim | 53 + src/battlecode/years/bc16/chassis/kit.nim | 314 +++++ src/battlecode/years/bc16/chassis/micro.nim | 67 + src/battlecode/years/bc16/chassis/neutral.nim | 70 ++ src/battlecode/years/bc16/chassis/rubble.nim | 61 + .../years/bc16/chassis/scenario16.nim | 134 ++ src/battlecode/years/bc16/chassis/turret.nim | 72 ++ src/battlecode/years/bc16/constants.nim | 262 ++++ src/battlecode/years/bc16/delays.nim | 103 ++ src/battlecode/years/bc16/economy.nim | 69 ++ src/battlecode/years/bc16/health.nim | 117 ++ src/battlecode/years/bc16/knobs.nim | 375 ++++++ src/battlecode/years/bc16/maps.nim | 373 ++++++ src/battlecode/years/bc16/rules.nim | 557 +++++++++ src/battlecode/years/bc16/signals.nim | 110 ++ src/battlecode/years/bc16/units.nim | 371 ++++++ src/battlecode/years/bc16/world.nim | 1098 +++++++++++++++++ src/battlecode/years/bc16/zombies.nim | 165 +++ src/battlecode/years/dispatch.nim | 136 ++ src/battlecode/years/registry.nim | 5 +- tools/build_sprite_atlas_bc16.py | 129 ++ tools/convert_maps_bc16.py | 593 +++++++++ tools/gen_year_constants.py | 209 +++- tools/map_pools_bc16.json | 6 + 62 files changed, 6952 insertions(+), 23 deletions(-) create mode 100644 data/atlas_bc16.json create mode 100644 data/atlas_bc16.png create mode 100644 data/maps/bc16/6147.json create mode 100644 data/maps/bc16/boxy.json create mode 100644 data/maps/bc16/caverns.json create mode 100644 data/maps/bc16/checkers.json create mode 100644 data/maps/bc16/closequarters.json create mode 100644 data/maps/bc16/collision.json create mode 100644 data/maps/bc16/desert.json create mode 100644 data/maps/bc16/frogger.json create mode 100644 data/maps/bc16/industrial.json create mode 100644 data/maps/bc16/lockdown.json create mode 100644 data/maps/bc16/prisons.json create mode 100644 data/maps/bc16/quadrants.json create mode 100644 data/maps/bc16/quarry.json create mode 100644 data/maps/bc16/river.json create mode 100644 data/maps/bc16/scouting.json create mode 100644 data/maps/bc16/space.json create mode 100644 data/maps/bc16/swamp.json create mode 100644 data/maps/bc16/turtle.json create mode 100644 data/maps/bc16/voluted.json create mode 100644 data/maps/bc16/vortex.json create mode 100644 data/maps/bc16/wormy.json create mode 100644 data/maps/bc16/zigzag.json create mode 100644 src/battlecode/years/bc16/chassis/archon.nim create mode 100644 src/battlecode/years/bc16/chassis/bulwark.nim create mode 100644 src/battlecode/years/bc16/chassis/combat.nim create mode 100644 src/battlecode/years/bc16/chassis/comms.nim create mode 100644 src/battlecode/years/bc16/chassis/dens.nim create mode 100644 src/battlecode/years/bc16/chassis/econ.nim create mode 100644 src/battlecode/years/bc16/chassis/greenhorn.nim create mode 100644 src/battlecode/years/bc16/chassis/infect.nim create mode 100644 src/battlecode/years/bc16/chassis/kit.nim create mode 100644 src/battlecode/years/bc16/chassis/micro.nim create mode 100644 src/battlecode/years/bc16/chassis/neutral.nim create mode 100644 src/battlecode/years/bc16/chassis/rubble.nim create mode 100644 src/battlecode/years/bc16/chassis/scenario16.nim create mode 100644 src/battlecode/years/bc16/chassis/turret.nim create mode 100644 src/battlecode/years/bc16/constants.nim create mode 100644 src/battlecode/years/bc16/delays.nim create mode 100644 src/battlecode/years/bc16/economy.nim create mode 100644 src/battlecode/years/bc16/health.nim create mode 100644 src/battlecode/years/bc16/knobs.nim create mode 100644 src/battlecode/years/bc16/maps.nim create mode 100644 src/battlecode/years/bc16/rules.nim create mode 100644 src/battlecode/years/bc16/signals.nim create mode 100644 src/battlecode/years/bc16/units.nim create mode 100644 src/battlecode/years/bc16/world.nim create mode 100644 src/battlecode/years/bc16/zombies.nim create mode 100644 tools/build_sprite_atlas_bc16.py create mode 100644 tools/convert_maps_bc16.py create mode 100644 tools/map_pools_bc16.json diff --git a/data/atlas_bc16.json b/data/atlas_bc16.json new file mode 100644 index 0000000..d2259d4 --- /dev/null +++ b/data/atlas_bc16.json @@ -0,0 +1 @@ +{"sprites":{"a_archon":{"h":16,"w":16,"x":0,"y":0},"a_bigzombie":{"h":16,"w":16,"x":16,"y":0},"a_fastzombie":{"h":16,"w":16,"x":32,"y":0},"a_guard":{"h":16,"w":16,"x":48,"y":0},"a_rangedzombie":{"h":16,"w":16,"x":64,"y":0},"a_scout":{"h":16,"w":16,"x":80,"y":0},"a_soldier":{"h":16,"w":16,"x":96,"y":0},"a_standardzombie":{"h":16,"w":16,"x":112,"y":0},"a_ttm":{"h":16,"w":16,"x":0,"y":16},"a_turret":{"h":16,"w":16,"x":16,"y":16},"a_viper":{"h":16,"w":16,"x":32,"y":16},"a_zombieden":{"h":16,"w":16,"x":48,"y":16},"b_archon":{"h":16,"w":16,"x":64,"y":16},"b_bigzombie":{"h":16,"w":16,"x":80,"y":16},"b_fastzombie":{"h":16,"w":16,"x":96,"y":16},"b_guard":{"h":16,"w":16,"x":112,"y":16},"b_rangedzombie":{"h":16,"w":16,"x":0,"y":32},"b_scout":{"h":16,"w":16,"x":16,"y":32},"b_soldier":{"h":16,"w":16,"x":32,"y":32},"b_standardzombie":{"h":16,"w":16,"x":48,"y":32},"b_ttm":{"h":16,"w":16,"x":64,"y":32},"b_turret":{"h":16,"w":16,"x":80,"y":32},"b_viper":{"h":16,"w":16,"x":96,"y":32},"b_zombieden":{"h":16,"w":16,"x":112,"y":32},"creep":{"h":16,"w":16,"x":0,"y":48},"horde_archon":{"h":16,"w":16,"x":16,"y":48},"horde_bigzombie":{"h":16,"w":16,"x":32,"y":48},"horde_fastzombie":{"h":16,"w":16,"x":48,"y":48},"horde_guard":{"h":16,"w":16,"x":64,"y":48},"horde_rangedzombie":{"h":16,"w":16,"x":80,"y":48},"horde_scout":{"h":16,"w":16,"x":96,"y":48},"horde_soldier":{"h":16,"w":16,"x":112,"y":48},"horde_standardzombie":{"h":16,"w":16,"x":0,"y":64},"horde_ttm":{"h":16,"w":16,"x":16,"y":64},"horde_turret":{"h":16,"w":16,"x":32,"y":64},"horde_viper":{"h":16,"w":16,"x":48,"y":64},"horde_zombieden":{"h":16,"w":16,"x":64,"y":64},"neutral_archon":{"h":16,"w":16,"x":80,"y":64},"neutral_bigzombie":{"h":16,"w":16,"x":96,"y":64},"neutral_fastzombie":{"h":16,"w":16,"x":112,"y":64},"neutral_guard":{"h":16,"w":16,"x":0,"y":80},"neutral_rangedzombie":{"h":16,"w":16,"x":16,"y":80},"neutral_scout":{"h":16,"w":16,"x":32,"y":80},"neutral_soldier":{"h":16,"w":16,"x":48,"y":80},"neutral_standardzombie":{"h":16,"w":16,"x":64,"y":80},"neutral_ttm":{"h":16,"w":16,"x":80,"y":80},"neutral_turret":{"h":16,"w":16,"x":96,"y":80},"neutral_viper":{"h":16,"w":16,"x":112,"y":80},"neutral_zombieden":{"h":16,"w":16,"x":0,"y":96}},"tile":16} diff --git a/data/atlas_bc16.png b/data/atlas_bc16.png new file mode 100644 index 0000000000000000000000000000000000000000..89f0ba6abe79fcf0bcfe6f125e894186bb8a7728 GIT binary patch literal 27538 zcmV)JK)b(*P)ekdQ)vY#=lVh=|y*1q&W*M^IF-V}lUv6}y6h9f8n9nt)0W z1c3mG^b*p0Pj*>lp5GrkK!Bj0^S}>u=@67?o?DTuKe=#TwppHKa0FYo$8h3wmZ;G^~&H&=$d=H8@x4kyUUvI4A z@BB++e}VD*m!1!ng=7-|nB<;Mj1TWi)1uP00kHka1OfnW8zc!x8kQcAx~8{(^2$B| zscXB~lAZ#h+XU&n-(aWybL!#%79&f_My7n`j}y{{1*WWxwxw+LH>ZsD{pp#3IW_tI z=AQR0p7IQBeDZnP_{6ic>XDfg7u@&hvjyb*=lfdEC?F;>*^&H3Z@-kaeFA!|g_JY~ z=o-~-VVoxjy&Xy4QvU(8yH`J&7L`^6bcs{g8KLzvC*G0jmvni%&%+sQz1BfW91Q?je_aR|KW8lQ2f}++`!N$T0Q9ye<@}V?kDln%tCcb`=$5cv+ku!s z1s4;D;hf*#03-&0Cp-wAI0Zau zD$w(~cI#`*wIRJ1z8Bw@cBBrVivm+#H!)zMPr6TQEY2C6G0ulJJ@X>bsudb7S*+6H zg(|IF?%g))H5wGxe79BRO?~?l^&dc@{{UJV-x_h z+v$&C6P-?ZZhpQNZb{DGnrNT-QA=xZ4%nG0z0udp&;i1{a()`sXB4Ug&YA-xFh_YNC8uCbkV2^iE$VLP) zF<8dnKpDqc3@KRQqEKhj#8TJHixQe%8Pvt!7?*oM13)qZbn4vcxh|n4Pj`_?^$1~N zT-u{@9BB2buUDyJYPgwu1T*d_a^1(o;ZNEGiJ$s@09n}b*gbIHqzJA-C%(H? z7?RB8g>`!MOQqgnghR*LCQNiRmqY+#v0?W{+P}Rw%sPNE;InGa;rR`&OKM%mN|~=S zou#$`U?O~zpKMFcUK3}Z8E+?jyPK45W)2W!1%~#1jCH^*VWFFJ3JOpdj58ADl$Rja zC~49sJQHafZIgvZ)kp$o5)?>~AQ^yER7-kVpJqqr_?8b@4#eWDECvAAgrphm zUKYdK#}2KR)zGdF;0p>sz- zkf_p{Te%b($QEwjk0~F2gD1Y)K(pRm!fRSwvjJe{%$aAc;2dNzpxm{zrg=%M%$jZs zQfOX`nWtJLc}2Z0Z*~i|p;?#unk4;7W}i8Gd(WV8xd+ra6CqPJ_yWq3!r*Ppcj;^A zRtjd4BuOVheXWKzK14C?PO@8in;|tZvfk-$nM5r0dGd=JDuW?=GR1BW};;f5d?*VTl;&o1QOizQWUGR1|N6Ym(8oBIin zDf{!<^otxaz=atG2{3RF2xJ&w3PxkXWwK$(%TNYPjWxhojAA=mr6d zy;G~=BD;iZ!ex}V0y(BiR^FSiaqxkxk-c$}W#OAi0DxQupk;Y&_1NXLN$DkC<D(mM`^9ojK=Tk zSU`m?#La;cYJ|ext#Su5fYJfv|G5cZxBGO9vaQ)bxxvt^k(FJbFW-ZFEya=a^om%= zbf;D5x`48|*B0qoN-hoxGhAm4Ylu+OWSle+G6J$g@}N3Am$-_UH} zq)Lo9mo$Q;6VR#>9n%T*hR~A^c=_a$-d79UM|Lvilj!}qxkQVzB#+jdvtsi;gfqeF z?KUXUoutlPB8=pSxXcK9Qc5=RjdSxAsO3RsK9As3E<+=C!l^jh*-x^N5M;!~<9P1x)V`1}wV*n#TzHi|S zAOjfckTEobVMncweN6%i17xO&WV2$(oC`yoGC~R~NXP4QDr4q-Rj1CpKioQXP&5-{ z5GgVk80RJcak`*|wS?{QR`fa8WYGjzdtJ`Pv!mnuD^@-$eNJ2QxICxFRt?l7Rj;3G?c7(a%e|iMUd* z-|y$tSl3ICn#-VxSP-fK)cd@jY?zhBJ*I`4mc0~XNhQs|k~$&1QSDi~tx@~XVP-cJ z>PGbBo&F=43FbA;jYo@`#4VH!e(F5{kRSl?HukYNjOpbnyqxjeYVQ|S-lH4aY^Fan z=Y0ZYRQ%Lp7{M|lk|e&cLH)eUeQ+ZHtm~kUtVkTM)o57VOsLQZHw4OP)CoZ{gQ74f z3@|jpaW5gi)xgGP1F2R8ff9ppT7V^?5n6PDjR4Lhpk5PrxL89kGlRd(AOK=u<`=3B zNZ{^M79;^Y6Pcz>6I)tGFZPHjVX|;2O!FGdJf+BsdQH@g36KIaEHbzHd8o5uC0Q`* z%+q6$B^>||s{B6ggKoA(H-vFx$?~Nc`E7 zPISuWoL=a~Go8)&p~2v}jlvWL!*In`&A9!(YOubj0Z`R$?^QT*G*T0;m=tPD&8=~Z zdDYs^?;7+MfWs>}fEF)SuBisrR3}{H)2E(oKtUzTIjxNtWrMMt_)brDvhA1@qC^#V z4Kdto!%cqt@rkvGDFb?Vo||=L;o{ds5DL8lqx~f<-OsE%RqWc+n^#}-cx7cP!KpChYm(ga$wSEjFT`1O&rvs7 zmelLG(xIT2KLNxAIHPAv)T1-X+zYZr)QdUD6F!5+*Pqbi_OjlgpBPZFwguSQr2g_* zwRaJ4WDd*;gJuQzt&n@x*&a+1{MB!QkzG+6EwiF>O@ODyM|Zkj4K11$t{}fv;Gz%( zAOUco!7Ie4)!_RLciro+9em6`01_IogWZH*DI0vs+%)6ut|kbbP^J+ob;6QXLu_mi z+-XKZkxuLT+ANb^C}|xI9LmegguH2!&jV_w!Z`pDYVG-QjmmezIIu(ka1FAgT;vjv z$+M9so@+DU$oJu_okf1y3s@W$Hujo}{!O((yvZBB2PB+2|$cgOT=BP1=x#O1fEH5R2XXa*t0EFr0rNr&Ld{U<%!zpO65^h?yX`mESo zT_Bq?ebJ{cSGHdLK{xxrYR#+bOz>W@D&eG+;79zyLa+O+@~UAntEkSwk<%Yfl&||M zJu=1yS>ArH##6qe&hyz}H(L!DzXC^^0f2drd_TJ`arD3w=Dc15OA5Yeb;x$RY`9v{ zW+#@GT^-g}Fa@-sMVHKA=uIZboKWKC{BVtCWF%6sza8@s1fUW31NapwkhyOoA&tPS z3d2`5I;Ka;Vp5>QQw}!oaH)sxSzFih>)0T5@9`<%ECy=(a$QeAFDJp95rxaZ#W|lQ=ht}T6yWMP>gjpTy^^g zP3zC?X%OHrS;QChs@UaW41xrK@dz1+QJ_6i>MD(`lhj5HMQiIDuLeroEC*c9N4_=@ zPGxRu{)}5rBpaV}v(Fq5!1c}zOdf2N5N2V3AkD3D<<3I)&V|(_mnp2iStD??eIP>) zI79hIl|h#^8J#xV6k_Q=IEe6MOCu)4x$*r$2R1kB^CeVNUz?O{$~|yEZNF~?nB~;( zTlq)j)vYfAM{3O6XMa@9lQTl>x?ndUzwVpEeRYPh%H>!C>_7eC-p`c2^?~y3b6@Yu zTk69wE8L6;O-=axx@M4vAxbuI^C3S;t+c`GhR0k62@F@#$M_uk(R$3IudVQ!#)L{F zLKMcMWQH=2KuUmuSTnbnRj0BS`FSVmfm8eNg_;viB_H|1AQ(73`Dc6-AW#3}Uoa#U zTZX?2eflPyU}}aUb*YnM>vku5Wojslqm~R$6%gMnMECeeD#4@Qw61(gJ$rb;UOXL;){X21*u z3V>tqhZg`}U8ladu2FB2WhS;a>9n=gK#jztn~u4RQm^*WUY4&JKCAgp!>Rem*V+Zh z{*@+%P^Jv9V;|Rg=1wX!_~j>D`by19Q@cq5cCGf??ZIOe_S+BH*^tkj{Kd#(QVv9C zyZM#Lp)$;pBt25EI+bYSN9rkE7HgFeZgJ%QsIor?qq2P=Bl5$>-NvCgn^lqgA z88rUSL&0YyGfoG!IY1lGmA=JCRLQDbxngfLcNcKx!K^X%yays1~c1zQS2PV7c$M)5_ z*m^EMwX*R)-rAnQd(LpD_t)-=?B%NJ-M7`-x4#A{;Vp>+F^I8rlqm9rr{e~{8PmIl zGWu&XI`uvdbWfNVk$RNUGc>S-H!t)ZXAS6xJQ!I>HvQCmF#Fv~&y(}(x$TK!_x7)< z_g$>8!UpZ^@MVW{WB_aiz$q(BG6bahGC*s_Z|t<~(^1RQ;afdtNhJ{&kxwXyz}mS? z?e!|l&W20`ZigJGLza_56;o;um#euj2ZllUEY3Fg>YR}7u%l~96}o`qD*#}Vpb3Bp zqOcztA?LNi;y%$i$bm5{2Sm^dn+)SN5Ti~Kv1(06gaP5l3^c2J8yk(9j^4Le!;F!KnmPeO0K~zW zSWu;MyP1mHRJ-v%KlQ$6aNzS2ue#^9U`hPYY;Z6`JQ&QC4RtuiJIDh_0121c^l%q; z2V`B}@6vfDq+@vicNyYlaAA5dm`fY#&{KcZ^?yk&zp0N8lg1OOq-m+SmXcnEfZj6P%Xzln!{#27!5Z~aSn2#f_V zq3vqJLr}**5P>xNn84ImAByUIVs`g*db3+P&FIui8x@?g${Cd075_p$`QM!Qzmn$s zPc??*ER%2YL!OB5@*pMkczzoI+n-FJ4-Y}a07x<{!=ghjTN561$;#-E%hm?l`aK08 z#5alA{K9yI_LI3-%FN7`+nKy+ox-nM8)EOb+25S_hj@r*o?J~Yzp#~Fd~Pev%Ko0B zg9l$24-u&JnD9VUI=$1a56$Y*OMRhhFYVQ?Y5MaqPU>V!TKQLuAXwAfyk5@fbRCxv;cF@yZ;q|`|oaY2SZO z>V}P^ZrMuSw`cF6l*G|98E5C=A-Kl?63iqbB&anylo}l}5s7{lGGxZYU$b1`ZqZO& z`>B#r-h_j{977WTX&l2TG*{cz5vnCmCsWUaB2{c+3?KY22>^3C=O}r3BlKWPTDCLe z%NefL<63Z7rcDs^l)kew;<`Tk9g%KhOcv*C2P`Wqi~A!wIJV0JD|+{zcS)zNHygSR zg9wJE!Egko8htZf4eF7y{71-_4b9G`(?UQb;85Zg3>v;;_Q1?-Zw6DZdCWL^^8hqctAsM2) z%z$A)F)2{IUdSUy_YomMF;9^o%$zy%EL%$U2q42M@&5kAFZW827+~SVASsf$*z=bJ zfL()T0KZedb{YK97!{9Nc@~Tn0OYpE`O6dVr5_)zu^9=)R=Ab3@u4mc> zDK0!&aLR2y_kWKA=A0ifaWps=p`$z$&-fgyt-49G;XmfGjl0f4sY zc+oqf7?T@`^tM-Z%DK6@nhDkkvGI?LR!nA($E{f;S%D%0hGBq+p|qk!zbtjXBTy~= zFeitlwt-mCE$YtiBRgMxv0p%fw=ES8n_q$)*=bBV17HZC@c27MSy8Q2Q?(W;2`_E6 z*}J0f#GHq#8uMnK?GcEorY1cuJY1&l)_jA5hjzefvXNr4OHEC+ueHNN&{8<0Fv84RE8K zzd?o$#&Bdy`_3&Y?s_4{+PT#*N{J5u|^=O;qyp z=W0RLzO&N$emFfKBt>g(tdkXUC^+{)6NC^7xTL4{d8P*C03t7Xn0dD&HV@kE92r`nnSc3cZL-W*b$Ne|N&8Hf}i=-2_5pNT`g(3zlI0 zcN)A}qNch_mBO-zJ^|1n9^$M`joR;NoKdJO*-VfEWPpIG4p(J%W+q>>yE14nH?O}q z#7J*A41B^vuan|+fEWb%6#AF-8g2J3CYzZ^*zZ{= zvtuP0NlY0nRYB;_@^_FPn1 zxv`aV831Hic7$-*1ZXNW9f*y&7{#@A5Wf>)fo2#wz&k&|x|ky-zA{Ig;?@*k~@ zW`jrt)%}tGGb=Ao?_%ru-n;~BI!s<0SpTp+}f0I+tjQ* zExUl+F9E00*W%TBI3NuRkf&^JNGt|;5ujG(Bmd|2&(SGn;7EVJyGHmaH#Hf|@uNF! z*>3jRI)l!Q3&F_0KsZ)u3nu79rw75p0zOPiePvQuWU}UVK{lIZNU{UuDTNR^hKV@>nUV*rRq_@Yvb!_WyqVFS=9W&p~H_uxduA#koh zlDM$hk_?GE1Vr;GnXPEnOI~k2`O3WV;xA{$#7&tR8s1L`!ALR5j4^P=6nMQcR9dtK z>4R2c+h(Ul6RoF(J0}MK#u+9=)O7j1;K#}IW{mBvM7xBet&09qb(QLk|-Y|68XR!$m zaE++CTGyum@Jc|d(H7NI*4W~A+pmoP`&k#l>nH{G8mKL^$N7M9!F#S z0o2v4f}dj`WW^si6W0<#Xe~nMHX`D(!r*l`;^5wF*c{1FOb(w<1j$@<;vF!~-DlPy z@tBxQ)9BHgTGuSUQFQ5khp<>}T5GdzKCtI$sNSl&u&B|2HopNp%&(plOAj(&vjVN` zOwGUn_$zVaQu@5JsB7F9+=RQf^z{FDkRR#o`*Y^Y1^nJBu&l|z?;e3A zbviB!lISWw6PtXps*A!(OPHLvenOCW)T^Cj5hybOi4hDaUV}rHfNvUfY;MtUf24xk zN=>`z+FD*$YuZ|yQM~lh2L0W3;o$`wxIqBQ{FoTr5g7@WszPQAj4@QJDrQwwc$;;- zbU}Unr3^TJ3YqI~Nqjo3@BHV2!+Psgn7u4C)+<3xLQMwj^VOJm|xEmOQkY&)J z-`_fV_~7FUR1A6%?eGvlzQ%#uFCA6)iC|tdRR4gw+FidC70q6x0&@Z+3<`;vzhYST zrM98{Z{#m8b>ryKNzsf+mjPFEjVLZ!$qo1R zx?i9F=rIJCB{X|ruzTp?IkyYRe>Oxk(ucR2uLv#8*G>P zcMUp1Wd_HtC@HqOcoi1vZ%=eO6(HZ*hK{+J0FoG^y9x^pg>&2*5do+^p^rA3*@v;Q zW&p9_1qGWIH#CeWee%g;ET>#)^2~ky@b8bA)133CcZe{zU3g?xVl`s0Nu6fsL0KWpf8IaIu zXy1E_-XEPWLh`OppH$FNGfcMIV~y6<0<^iMW!<|wgvlhRzGkyT%nq1Sz&Oy_$azV1 zlQB3UCKy)CLy&$J9)j7>qtB!$%c4*dj~9l3#29ZCgqs6p3N~?e=@EC$13j(L_vYto z9r=V8>VI8;jC6+taNs}-VR@sDV40&@qlWAf&q#(ty9rs+gV9Iz*6M*yr7up^?4y1AAYVJ9obu4!>X+ zIy6J~D*JzZWao+E_s($x+cYWT1QG$=q9<e03189{O#(7 z@1`avU2i&YAona)9AHkmOP2hSYh62D1W+SM5)4D2W*%jP*df?W$fwn~7Xk(BDz>J* z4O{=2t*L%UDZ6wJhFsCd(-w+pY13T=`7=CCmP-PJL zF{m$BYX^3@g)!W&@UQE&$-c?=0ZQt$)RPUm+|ABFuUBboT#$?!kMQYc0=73AxH3eB zRU+esautL8Byn2=7v?(iD?isJ>;sN01~44J_;wRwD2%aN3kt{pFd-rW&@^T-bxl#G~b9JY4j*_20Dd*Q66VU&nbu$H#rfbcLt_SO6=*oe8PwhBS{N4!0 zWEYVxo#TNJNJxzF#*<}pM^u)5J1Vl%RnL4^+N6)qc-sELrRx3>FJkKAY?#L7fSV8T9!h(bUr~vo?$i$$Y!9%bcLsB+;9c#OUDWpQ2DhdVQ zCYo8TDUAlvo8|k=7qS65?9TvbL{}Afl?qK5yP6Kj9mM|ICM*7ipL+&;Jt&yzt7~=n zt2zxyW-cXJm~nZaf;m+hngrqgaEpAxE3m#kTDoU z$c>G~*uX$MR$5BUx?VQFwssVNgE%L>otgQgoR>F3j|du*6B#{rhSeVHitaSZ_Uo=G zzZ@@q=Q76RhI3YaN68=}GEUawq`^t`yI_YI^|*)KggG<^ip(X` zB|o_cZjDS*J1buY!`BU(tHEHaop2(bIxb1LP=A#eN;Ki}7_{a##8|FCp$24O9CFXX z1(>-U{EM5(?s}4=O$wqF!n_*KqyGrCy%ynS80Jv0)dT#az?ENRP(^o>BHN)a5W2J~ z>on*R0>JeEA^=Kn5Xq?$R9oP!4!-QU8@o4cjObc&qSDma z>;sdsNzfR0;&=#gj;qfEpm50Aap*_3;*WqJ|Hb}c=9IW<4MQNbwp={dVUo5$6~iF} z`Kl2V4$Zp=IOhF>qR2mu3U(9+=rFjIO*|)u2^;S;!6iypGsbI)g!FI1z5X*%I`?6qk!T5Zr|FCgM z@y}4Ot0U4+Z>&`6LEOGb$V0d&hK<@>*VtKRYiYQ!<<*E_^;t1#Q&1&SS$hKtlc|L z{3a;ad9l~qB)WE=%oURj08m-J7Os|ks4DyM*~-RsGkk-|+CjA>5E)@T<8#VW`_2%$ z2Ic6%R|<|Ep8l2q-)oXWE&T^AURPGKyy$4*Yca`5i%bU&jMLBgUa6`QMVXC*@Xt#qC`&U*5&Tc ztd3Y1daVYS7-I_K%#aETKKZt;HUDZSCMx-utNjrk0xF#BW~`;fxC~zW&i;Xtza|iw zGLWZ-+cVOC*by6^(CvwJLoVJnd+5mRvyqq+Xob}tD@^84RrS^>np!Ut zk+A#sGC1V_2p+wB`a8Y$%177jv}Yu zpVY@Q;)-}EHsP@W{y_;Yn_mxCxA=z=32c@xZF+uvydj$5AL!(ryI#q=_jnrJ6Q&_1 z?p_ueK1>2v{xTlIZvy{4xR2-(m5Jz#naP4NZY*Kkz^Mx`z?K^4+EONj^b8D0`#!2u zR*)`S*=-Oo_UHD^e@~Mc<3^w(ZE)AF_x#W!X_8r0L7ah=6n=t$pbW&sjYdO5J^X5} zWf#XC#7(#QWsU0H+^#-;90SmDvTXj?lV$VMJ*{P*nyoR3k)5xVh-A%VhHQ6qgU$aw zJuPv6Mwlffqp|waDa(G6FRN--A3tM;&U12DtE$#EmKNm&Xlk_xkGdAR-oT0q-VOfi zDv{Z_WP`3jX{g@~%~K6ctG5184_#}55CE4fV2ncuLy{y(k4t!BbWQ%1qw;CaMzHz| z;vqPL=t3g!qHE!I`$&HG8%qy-BoW+tJFme42YIN40qiV1gofI?;uD|jtm!Q!M+#s6 z49+GI)yJ74aeFv;xkKode$%w`!LR}sP@$AEFb z5W3-zi>1LShZl`XgB656v=CQ=;}bRxTYNDWStHU9x!k3V-4dn>MUi#GFw8~AKS68r zaVfm(RCMiT^;Wyad5e{C{<3n(^NeBmsg<9T!#HESaR1@Q2Sf&4_EWzh%R&v^Bh^&= zT5dB(eBAtW86S6miN(p_NC9iBiaiTfO++l%k@itp(dReHCjYRSs_)5a?NV+scbW%1 zT8T#(55PnvANfE#M9~?~gWCTSnM}bpNiqc(oD(pNb&Mm-=DT;V2{QaITOSn{nZBiH zJ4Ey~*Y%4vV2}iWz(A-dU5<$8YfxVD^;;2LFMBLDcB9D6%{972-twr`-b3X7n6i*D zsf2dl(*|`(S2A%WeV0GP4PE)y+zS0o2HJmnk&q8RjZFSc#hn1 z%HKW{L=gbc=OD#T&n9a_`;9a-=`q9NW+_6#;cS48;^c5|?;o`8S`} zweH>s0LT8t)uHPxWEg54*oB{(mzSdlJNm!ZyYH-Y#Q`t)L{oxI783VmRLvHk_(UGU zBCkYM**99}ZWE{O-8pVoetz?pfK##iC2IjeF;ZCA=qY8@%ZfQrWB^q_{?B>s&h#KM z21bWmeOGYkWjCq1$5CCfe;HFhS=TmZXW=1mfjS1p6kpW3T|kR<>pXE!O4=)Z7<3+0HU&+1bQ$a#(b7!pl24br~P!b{7gu$Y8G3Z%65| zf3D7mYk6+km4%0UT%bPwRR4tTPwwd5|AnZM!tX^>!#3XBxF4#wcD1I~tBjiwOSONcvp0bdl&|3`;Q!-m6@GQjbDHExDnfP zDh}*@0>Pof5fGSw#)hMMQNjDG6giMfvaPb!wSQe);>^X75hJ9E@-@cgeO=O?U*Fxg zdT&7*AkM=>*zJi|>3Y2m$TA=VW1I`2RjX?CGyq=4q*fw>0}!&s9NEWY34pE%=601s z)2gAE4Drk6>$u&oXMw6#uMe<9PFl1rR9}=@ySJ@Ao=$}*+f*r6;eK<+&zD7c-L*U2 zC|v?30d$=NBonZS1H*;NX02!^((U5`Y}PDU00{K=PmOZ9N(@)y&(bYJ58;N?4=5~n z{}oyjzg!7G`}%mlcJ=XR`Umui5`Ym9)L-%o=nb>UM!)Z#dPPF=i-?GJ!t2)X+wOZ# zR+ViQ+1bRO&0&JbAR>V1OekIm!7SELn54j=066DzhpUQ!Si4_hC_(6)>5MS~wIPEf zS%%u&3_w8jRzZ-5x3(Oi(C|xp0kF@SCD&V}$PI!pDy8qc!fXi;oPz>GE>b#mop6mL zOQCtB;4SYIR0%Kq@OdwhcRXRbs;r z)vQ%bQlG!+r1HXwaEx`HfM7=$Z!q zfEd(QTMhq!KCHTYr4|u=jk%=g(>VYtiJp-m&0W-`+nuAV)&SvhHOnMaD5gN-l57wF znYIxC`YeV$8vUg}l~Vy?#b1oror5fv1d)-_H|>+(yT#uwblu=36$NN+*h!oR zv2?#1ybE`xUk?1#QGGnm&L-sKSYp$XUOn19agt%^iX?;zvsuP+8FL{d^*j@~nE1`Gpetlx)*x~=Gu zbPqfpUld6(0fr9j-}^99`n&Tiosw7fDU6zZV5M23#K!mMYg)?O|1A%UMWR5o>NA}-~XVOi0~isanIf_C7Dfjeqi79nueO6D5~p7rJ{60U0wYT^QRkX z6*fUCJh|y~I9mv=;kXnFJa$%r3-Fy7I( zb=cq08I5)M(6knEwbZDE$6nu8Rry^hfQqscAKmOm)2>tYPk^J>?LnbK0vVU+*uiI6 zKyU^mDFlY_Lf2iWt=fu!kXY1K<{6C*2S`zb2n)*~-H=DrRihm-7|wR znaazT2(vj>L`0+uU1wyqDom0f|N85bHTCs-#u-LqHDin;)NkmDVWaY{P&MeKC%=>{ z%U9@8owDQ;M_*f2*ShQ4KMRHaH$9;{vnd30MqwtL*CbT z(^NymSb(z+M`-6C59*ydZ)K03cLp5X_eRnF!;g&V(e3foJyT!qwqce1Jr7(Do_a?6 z&M}?t+|Z}ryh|)rsD|O&54i-mS_GXu`lg~A&2;G4OfzGWb}G;RXZ;xmydKc6JMK_a zZ>3RQxAqV45V+x@A-lhh>e5#)E!-%V7q5WL5hvN~2}VTp#V9)Q1$3hY-D2;QU9Cl^ zulW%*Ra;SCo42}^s;>l5hj<8p`FvS2;B@99GIA^q9eRvZwYjXgc>ZZTgiItD1H!7x zx65Em_7CiX#`<66z@T(+F8#;r<7Fu@NRbseAfS(Y^5|-QoeBCncKS+hRZ5Jg+s z!m90Nf`r|k5)l!Qxi!A$ohUrE82b-DGL|uZ1Z2cpZ9sU$wNtBV*XMfl-CNJ?uW-Lh z8tZqUrqW|NlL;jd2~?J^a2a~zY9}TlpXPR)V!mL110GMAiww`2n3!zKfWj0FSwL>? zegbmZ;UR*8TN;0r%i)%7)@Ycmkti&l^Jq-xyI*T zGs(Q5RaC8NOOSPlhXBYRA_-uWpP$9-_W0`7)??EExEbROr}8Syhe?Ya>GGcFWcUi? zxt)iIR#D3X%yGqy-s3<1hq6^Zm8it`%^0?|m(4F+?8|?Urw@5klob4+Z2M*8T7XrY zpV?yo$kr?Q-EnDs@6+?QKg$p4^A@A9P>t<;-?)Aky=y8y@q=`s{uYFcS5YgSBUM5I z=Vm$Lc#B^Chj@ky_gA^J}bAZ}IMxU|)2p8&wDO|w3Da{H5O_w3m-V8NCJ(Et#;D)#2I zHEHut-{0}%+MPRh4p_fseRRi&3ZE+UgO6{(mnr{id;=FeVPC-?CW8KmZvcOpaKQIh zCM1ltpyveNl`yXTH(l`gyzL#84yHzF3kY5aPth%DT+F226d35^8}PlwzsEOl-gmM; z9(x7?dwcWS{R57+l$`#Z6p-9p#7mH@fG4R za0ilx*ixh?gdub;aYe5*eyXc!oCB$>7RGtM;gkMzhiPG!g#A?v&*tgAzfMSt$WUI^ zj>4|D;`t`;{`ElC>66Zol_eoJ*9f%syf1RT{aq=zZArNpDyF!fsVm<9{HedW@apRs z1-L0R&i*PC7Rnii`=I`IGg|ioMdio_M-I-zQ-mcZeH(Mg?@BFSpkbj}T=-i32$+hG zqb+LYrzGBCj^~v%2M_)wJVj`aluwz05ThRqG=K(BuV0+h>F%^T{|@*D zz-J`|GT;~O|n6DR->8P;`5FMS~OQwPuh>Nc&{2|H4zI?yxIhQ!e+ z8~ah`+foaC&+nLT!1o@>!JV${wV_{q2069|eVabX|L-`4Zec5vUbZLVKEzdrJ6 z)2ln{8tMxC$v+SPRHiy^`uV0ymb5GhPB*3B{Yw2StBhu&*rwY80l<(9`^5T*BOR*a zG~WQ`IAu)A*8)S6v%9UZ&uHH4)$C&=n}DuIePzucJH9LZENTSgau@Wo`3A5!i~B#k zKfrH@v>1GO z2yo8fIijKNmE_&r3?hv={j+4)N08CD*h{tBo0kIJ6EHXbG~WPHvKWk9m>m2*$Hh!& zx_~4@VZL*(h5ukCY9jel5SWRXAhbU(aDOfVQGy95y9bQpZ@PZ>s&%tf zp%&_@B|{1G`z-N}=2-_mY5VfC>k`K28bEBcZDimjQlOz3Al|%FH_SaGIp|*V=bqPl z4pZsSvD4#r+URD|;oPv?!(8DtOk?HVeD77HQ;SWt6~>$5QyH-X-$s3VSdo>6Q}=}~ zTFkn+z-e~+vUwAIlOG(PoZV@GeWoRW^y-&Lv0uVLfDDtNMbEVkfG2cQ=_1c4DD{nJ zwgab(n{qWnBuog)_8ZOYhDZ03g_#+Yw%Py^KvgB_mj^dH_DRNv+Zj@`va+Py++0Lm znfiUeIMcs{)nY*kj z`J7)0R@)59l0dmSSZsL61LkLxSVI^tGMj9`;mHOD{#XaUaWeK!34xzY$Onofwc2Ki z3kXQOBe$)-p8M6N+cIS^2($2o_qgwoh5;}z0UkcxEV&K~*hX`bEu5NH>L*l>*a8AW zyRbGfij)4rGn$bw1xf`O50C)hmQh!MbzW9icm_`a6&y@y)$5JM#O#pUIMBW zR{Op?7#xJYIFB@dZ>inVe|gICDKC`1Flxl$5yu7$81QYPk~p%JwRZ9s{(JLU^PJzt zefz)*MeXjJ6GyhlEs<8T=HughEx1S0)18+)rknZ;opdO!O$L*r7))I`gnrudmPs3*}imG z^hOHu?Pe{k^y}uN<1wyB%kS~1qL%U2bq2HoVw|`H4HD^q)=Fd?C5T#~7xeV>+84Vl#TAO)UQ8aXwbO=$rnkqW=Lq9s|$4y z=|bkp_a48WM|fQ?!QNa3^=!TYP&WL`i+cn=Y+4rmx+PU31C2|B)bOHbZPRY;LzB#I z@H=TluYK5mqy;d0RyH1Ot`fI^z;UKngkS(Fyp5K0hf)2c3dO^Cb*}e|riP;%b6Ce< zLwV7tMJofB2TqVI(&eqJ^_wH!BhR~Ru4{kG|E>6F%h9syGp}D8_)XyLT;Zcn-FMsF z*Z#We*P^o4vhtgTKZN=hHMD$7&}#_(*UHeF2vQJ(tS~SI&<%oioZ#7Epmmc0YoY=k z%mB|Bz5&wFLKcuw1jOUNLZ%$0fNbo#2?XnK=6aet) zPoFv&V_6gA2``jbOZ=O8^Rq2#%Xhk_+XrO~n*Z6B&(axK)&#|OzlFCv*Q_?L6PoZJ zIAq{le{ z(<)mboCg(_$UwYZU~h5(r#yFshuP9<6-Vy}`?vSMFGkgOvm1#!A z2POzX&`SwM1;Mq*5H0HkHw{FAc7oR1`&uTQc-91PXv_vLxjC z_y&-jC-8im*o)Ij|4e@c0$OZp;=;gvJVF@y4~BHxBk&?+jHV>6x4r34?O?Ppne1dyt>C%$Rpwu_} z+`u*$hULH|q1^R(&}E{v(}sYXEd7Ir5G=PeV&v^^Y}xNX^A>$Rq>5@>n`}bv8NLB9 zkjX5kde-reN*jE91KbP<4XF9(4BvqJv%`H+gR$&RtYO%Hdi3gZmA+n!eEY-ScICgi z!Vvg^8S>aBJpOVsbe(}!8rU(yPdenV0hRFJ9KHe134N_rZW_bGNGPF<%TWw!nSfua z0&{n6Y4&$2o?rhO-vHoLK=}g3zt1-Sl}nabCOU1j;>R6UsM`#JzhrvjUBkQl*TBn{ z|8h3pz<*C1U~aySIZmZ+QhtuODjD5(*k=OJM59oQFs?JNq?Au~?C@c8jlE)pd`wx*X54_7H zelh!T3S=aQFm_@+6*k`9T7ge706r?B)AjY0>(mzmR-1+d{!_BGY}1+hCxh7sDpK*a zF2h@@eS{MSG#F0h&q}}Bkj?*Y%3WY^ri{GOV+|j9=BoS56V(z`x28%`=atBP%Gv&7*W;_M?j+}NWbvi<3T0wraivrefTi;{HjvZIswehY|4{UkhUe|8dVEUa* zw1EQ7y?`zzh;AvZvZjp^9}Hy zYJTdsEM?gPvsz|ddg-7`j}90x;M+Jk?&2nDil`U$dpEUgO8+MIoBN-2Jv*xX{bt@A z>L>hmp7m=vFp!mOs{Q3)cHM`bCtK!nTg4;p?IpSUE{0Or@F&TjQ<{Kb26z@_NrnNb zehZ-0;NLrw&a%apVUi*L=lKRWV?mNtmaPM1YyA_#W;Sy^k zLc>}3_MroO_b)Jw91Ow_Jts5+t05h#h4GiGd;fQeWH|^THlW*c{$rz_mZ|&+mHFSw zVNI1q(-H#*vs%{yM>^sgKmd|PYM@S{jc-5!6B;--OH|xw9RAPZDUj5+d!enDxhoST zx2Qa3fk`xcYbXb<_O1m#P;rj;@MraB{|f(nY*#;|Oz=a`33mKH&Np!SYA4%uA_Ps3qn|KIFoO4JL|Cd$+&bt0+Z%s?HM~qH=HRitFCwjb=PCZ^rr*2R6 z(jqTOSrwK1{~q5!R+f)%z$Aw~as5^I(7ks(u?>Jtmj6260ALxGo^sim2)BY?<>jd;>!UT(dSZ$k`#^0M4GMu0ek2`Jf;E zoPXZW)?-4f;;uGb8A~$~OQ81cv|b^9^KY zXUhPP)FUJ3o8{lplzX3Pb+x+b+AHqb4j@ogF1U(W+eBF|8IiT&-M2oa+PXR_DJi4I z#wOahelz{!*8A(w0pEacbxQz(9|VG@0HIR>$60&>mye#X@SWM8(hnQA(xCLqUpJX# zOq@7T=@1=cM{IbPJA3`od(Qysy>|e0osxDaI5@s@8<63=fwd_;Cd9wh*GO2<$J=#E zFN&Gci@LwiOHE#vt_MdZulP#>umXO~pCh&QQzfPBW5jIFhCcu)>#W-P3)R;HCiF}Q zKyqE6rKkSCq!HM}iE|VH2n|ll-m(2xdgIl(>eAd*)Y8(VRae#0P1oP^i%%0s=WYiq zYjKv;*}rpe*41~fD=8@_RdefQWuPW7nSJG&3sb>Fc?6>1>J}M zB)-_UYyf4Dv$a1tThr?@C@`?+{0mK_1wAIDZtF(_$f>2bI4RxjBqyb7{V9Wjx+X9C zSKJ^vip!`3gNWdO;!7eyZo639|Ig714o5=I(75E{E;sijhbi&twqcR}+`jqmYo}II zeAfZl>(^~2BGUeG(*yL&_FYt4TSGN9HEK0KX(8KP|n&8oQkhF+3&%h@^+G{ zyWIsR3aGlenwlExsl2S1Dk{oo=boe5_g_q>;i)OV`+!Kk#c=thbEpcbDgX$eDR-FG=CVTi0J48=KRX+{g~3VX%c**cm^T2$jR?6{!e zDxJyy=WC$}H^^@0Wy6(q(Y%CJdE}aCzC*66^GiiN#h?1fTbEnSXc;u0{aLvG2d_8*_>YIV+ z2tWC^0#ZGuu()8q~J{Jhg{SjvMifhTU?T+>WbIvg~#nST3xM%Aq<-N z!o$)fFctSblx1KyI!PFx>R0lEOq$PtQWg$4ZdVw*LD# z6*zo+1IWuG$S}YFGg9IleqvfJyD;AXoZxVP(9%vw93bSK&o{7V&s;6B`_QM~oAc82 zei?k2?A(=tG>b4fPc-fq! z#miQYpYV@sUzqm9{ic0;4yaOEbWg*mW2Z5g)xw(cAe@YUDncYGcoQ+ymHq@OuwP>8?} zUwZZFNh3!M(W5}iUYV{`=HTp7(rfH0^ zGkgI)#meG4ce%^9qw~j>sQPcc!n2iNt(T@Ll}V|39aL^&U={*t%&r!RTbkLS+KxgB zOacr+l5GfMQCBNZm79@=#d$_YO4m$@BZ8+}MoQ4NMlUzj%Mw!rNjiK7pt#BfyuSz- zRfN_&gIjugMaaF<W-I5=rktylZ_1|XsJ z6g*v#g7Cq+Un1|vpHWs_jg&v8q2#W}Yp$ifM=67a)3W>{ZU7W@- z^32Rk)22l4!Fh+Th+s69ERGmlKGCd-ogU z7;EYU7~bt+I6s5LI4J|cwF!_dr(zhgrS3mYmYzrxbpOq zFYXz8UF@(oW`EU6B3)rTzjDs}`7iwAws9}?%SiF;-Lv1aY{_@HXX->GCq%$z_ZMc1 zm7aL?#iJ|NEnJYD-G5$APBHg|zn#kFpYlA6vD^3VKJ<{T>3H|OPt04s-}%d;9WIMmZZk{`sa)RpH&qm=9pdxD*b^ z-Jw-H?ka8QEU9_}iYk6+yc(>;4F*miyBx-u%n8DBW+TSK6vnWica&rgDPHv~&gUBdftbXo@UB9GD;yEGMnG)?0hy!CEcQbm-#|fl z!RCg=4I`c`ee&3*11cx{L$P6tW)Pm)HgOFVQ?%m zhY!}>ZnrY|t|zxIS^n{*U}y#T_y&ZL%^7?3(|6zg`07u-m_4wnvQ}3;t$fI!D5|Ir zpdgEh|MRv--Sa+weV8n>>YSXj`3C%Loij#{9`p7mA1^FlweH)`j-SYH;~QYjfCR?$ z@8^B^#pr&?0eO8soK~=Y)ezZkk2PGbg461cg8&4?#l}ADcQoV)$q+J=NF_)L^p-VD zJ~*~+DB$CtZ;NdEit=nd`h?zPStN(@crpYaGcLRY|C?lzgE=c2<*w;A)f%0DZ@$)1 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 tw-@k#pCJ5cQxpY%;neHD+y6oB{{ua#Uw 0: + e.fields{"cause"}.getStr().replace("_", " ") + else: "the horde") & ", game " & $(e.game + 1) & + ", round " & $e.round + else: + label = "ARCHON DOWN — " & e.fields{"alias"}.getStr() & " has " & + $e.fields{"archons_left"}.getInt() & " left, and " & + $e.fields{"gold_dropped"}.getInt() & " gold is on the ground" of "archon_relocated": label = e.fields{"alias"}.getStr() & " walks an archon from " & $e.fields{"from_x"}.getInt() & "," & $e.fields{"from_y"}.getInt() & @@ -1549,6 +1621,250 @@ proc bc20ChromeJson*( } $node +proc bc16Archons(w: w16.World, sideAslot: int): JsonNode = + ## `#bc16-archons`: THE HEADLINE READOUT AND THE YEAR'S WHOLE STORY. Both + ## factions' archon tally with a health pip per archon that drains as it is + ## shot (1000 hp each), a GREEN ring on any archon that is zombie-infected + ## and a VIOLET ring on any that is viper-infected. Lose your last archon + ## and you lose the game on the spot, so this is the only readout that can + ## end the match. + var factions = newJArray() + for slot in 0 .. 1: + let team = u16.Team(if slot == sideAslot: 0 else: 1) + var pips = newJArray() + for id in w.execOrder: + let r = w16.robotById(w, id) + if r == nil or r.team != team or r.kind != c16.rtArchon: continue + pips.add(%*{"id": r.id, "x": r.loc.x, "y": r.loc.y, + "health": int(r.health), "max": int(r.maxHealth), + "zombie_infected": r.inf.zombieTurns, + "viper_infected": r.inf.viperTurns}) + factions.add(%*{ + "alias": aliasFor(slot), + "alive": w16.archonsAlive(w, team), + "start": w.stats.archonsStart[ord(team)], + "lost": w.stats.archonsLost[ord(team)], + "health_tenths": int(w16.archonHealthTotal(w, team) * 10.0), + "pips": pips + }) + %*{"factions": factions} + +proc bc16Horde(w: w16.World): JsonNode = + ## `#bc16-horde`: THE YEAR'S SIGNATURE READOUT, AND THE ONE NO OTHER YEAR + ## HAS — the horde clock. Zombies alive by type, the NEXT SCHEDULED WAVE + ## with its composition and how many rounds away, the outbreak level and + ## multiplier, dens standing, and the tiebreak countdown. It keeps its wave + ## composition and its countdown AT EVERY WIDTH, including 360 px, because + ## it is the readout that makes the year make sense. + var nextRound = -1 + var nextCounts = newJArray() + for row in w.map.schedule: + if row.round > w.currentRound: + nextRound = row.round + for i, kind in u16.ZombieSpawnTypes: + nextCounts.add(%*{"type": ($kind).toLowerAscii(), + "count": row.counts[i]}) + break + var schedule = newJArray() + for row in w.map.schedule: + var total = 0 + for c in row.counts: total += c + schedule.add(%*{"round": row.round, "count": total, + "past": row.round <= w.currentRound}) + let level = u16.outbreakLevel(max(0, w.currentRound)) + %*{ + "alive": { + "standardzombie": w16.zombieCountByType(w, c16.rtStandardzombie), + "rangedzombie": w16.zombieCountByType(w, c16.rtRangedzombie), + "fastzombie": w16.zombieCountByType(w, c16.rtFastzombie), + "bigzombie": w16.zombieCountByType(w, c16.rtBigzombie) + }, + "next_wave_round": nextRound, + "next_wave_in": (if nextRound < 0: -1 else: nextRound - w.currentRound), + "next_wave": nextCounts, + "schedule": schedule, + "outbreak_level": level, + "outbreak_multiplier_permille": + int(u16.outbreakMultiplier(max(0, w.currentRound)) * 1000.0), + "dens_standing": w16.densStanding(w), + "spawned": w.stats.zombiesSpawned, + "killed": w.stats.zombiesKilled, + "tiebreak_round": w.maxRounds - 1, + "rounds_to_go": max(0, w.maxRounds - 1 - w.currentRound) + } + +proc bc16Econ(w: w16.World, sideAslot: int): JsonNode = + ## `#bc16-econ`: parts banked, income per round (printed as `x.x`), parts + ## still on the map, dens destroyed and the bounty collected, neutrals + ## activated (and how many were ARCHONS), and IMPASSABLE SQUARES NOW VS AT + ## ROUND 0 — the rubble story, made visible. + var factions = newJArray() + var impassableStart = 0 + for v in w.map.rubble: + if v >= c16.RubbleObstructionThresh: impassableStart += 1 + for slot in 0 .. 1: + let team = u16.Team(if slot == sideAslot: 0 else: 1) + let t = ord(team) + factions.add(%*{ + "alias": aliasFor(slot), + "parts": int(w.resources[t]), + "income_tenths": int(e16.incomeFor(w, team) * 10.0), + "parts_worth": w16.partsWorth(w, team), + "collected_tenths": w.stats.partsCollectedTenths[t], + "spent_tenths": w.stats.partsSpentTenths[t], + "dens_destroyed": w.stats.densDestroyed[t], + "den_bounty": w.stats.densDestroyed[t] * int(c16.DenPartReward), + "neutrals": w.stats.neutralsActivated[t], + "neutral_archons": w.stats.neutralArchonsActivated[t], + "rubble_cleared_tenths": w.stats.rubbleClearedTenths[t], + "rubble_created_tenths": w.stats.rubbleCreatedTenths[t] + }) + %*{"factions": factions, + "parts_on_map": int(w16.partsOnMap(w)), + "impassable_now": w16.impassableSquares(w), + "impassable_start": impassableStart} + +proc bc16Units(w: w16.World, sideAslot: int): JsonNode = + ## `#bc16-units`: the six player-type census with archons emphasised, UNITS + ## STILL BUILDING shown separately (a soldier is inert for 12 turns and a + ## viper for 30 — the single most confusing thing on screen without it), + ## units infected, and robots lost / robots turned. + var factions = newJArray() + for slot in 0 .. 1: + let team = u16.Team(if slot == sideAslot: 0 else: 1) + let t = ord(team) + var building = 0 + var infected = 0 + for id in w.execOrder: + let r = w16.robotById(w, id) + if r == nil or r.team != team: continue + if not w16.isActive(r): building += 1 + if h16.isInfected(r.inf): infected += 1 + factions.add(%*{ + "alias": aliasFor(slot), + "archons": w16.archonsAlive(w, team), + "scouts": w16.robotTypeCount(w, team, c16.rtScout), + "soldiers": w16.robotTypeCount(w, team, c16.rtSoldier), + "guards": w16.robotTypeCount(w, team, c16.rtGuard), + "vipers": w16.robotTypeCount(w, team, c16.rtViper), + "turrets": w16.robotTypeCount(w, team, c16.rtTurret), + "ttms": w16.robotTypeCount(w, team, c16.rtTtm), + "alive": w16.robotCountOf(w, team), + "building": building, + "infected": infected, + "built": w.stats.unitsBuilt[t], + "lost": w.stats.robotsLost[t], + "turned": w.stats.robotsTurned[t] + }) + %*{"factions": factions} + +proc bc16Siege(w: w16.World, sideAslot: int): JsonNode = + ## `#bc16-siege`: the endcard war panel. Per faction, everything the note's + ## §Readouts list names, and the TIEBREAK LEDGER — all four rungs with both + ## sides' numbers and which one decided it. + var factions = newJArray() + for slot in 0 .. 1: + let team = u16.Team(if slot == sideAslot: 0 else: 1) + let t = ord(team) + factions.add(%*{ + "alias": aliasFor(slot), + "archons_start": w.stats.archonsStart[t], + "archons_left": w16.archonsAlive(w, team), + "archons_lost": w.stats.archonsLost[t], + "units_built": w.stats.unitsBuilt[t], + "scouts_built": w.stats.scoutsBuilt[t], + "soldiers_built": w.stats.soldiersBuilt[t], + "guards_built": w.stats.guardsBuilt[t], + "vipers_built": w.stats.vipersBuilt[t], + "turrets_built": w.stats.turretsBuilt[t], + "dens_destroyed": w.stats.densDestroyed[t], + "neutrals_activated": w.stats.neutralsActivated[t], + "neutral_archons_activated": w.stats.neutralArchonsActivated[t], + "infections_suffered": w.stats.infectionsSuffered[t], + "infections_inflicted": w.stats.infectionsInflicted[t], + "robots_turned": w.stats.robotsTurned[t], + "enemy_damage_dealt": w.stats.enemyDamageDealt[t], + "enemy_damage_taken": w.stats.enemyDamageTaken[t], + "zombie_damage_dealt": w.stats.zombieDamageDealt[t], + "zombie_damage_taken": w.stats.zombieDamageTaken[t], + "repairs": w.stats.repairs[t], + "hp_repaired": w.stats.hpRepaired[t], + "rubble_cleared_tenths": w.stats.rubbleClearedTenths[t], + "rubble_created_tenths": w.stats.rubbleCreatedTenths[t], + "parts_collected_tenths": w.stats.partsCollectedTenths[t], + "parts_end": int(w.resources[t]), + "parts_worth_end": w16.partsWorth(w, team) + }) + let aArchons = w16.archonsAlive(w, u16.teamA) + let bArchons = w16.archonsAlive(w, u16.teamB) + %*{ + "factions": factions, + "ladder": [ + {"rung": "more_archons", "a": aArchons, "b": bArchons}, + {"rung": "more_archon_health", + "a": int(w16.archonHealthTotal(w, u16.teamA) * 10.0), + "b": int(w16.archonHealthTotal(w, u16.teamB) * 10.0)}, + {"rung": "more_parts_net_worth", + "a": w16.partsWorth(w, u16.teamA), "b": w16.partsWorth(w, u16.teamB)}, + {"rung": "highest_id", + "a": w16.highestArchonId(w, u16.teamA), + "b": w16.highestArchonId(w, u16.teamB)} + ], + "decided_by": (if w.tiebreakRung > 0: + $u16.Domination(w.tiebreakRung) + else: $w.domination) + } + +proc bc16ChromeJson*( + doc: ReplayDoc, w: w16.World, view: ViewerState, + frame, totalFrames, gameIndex, sideAslot: int, + beats: JsonNode, gameChips: JsonNode, ended: bool +): string = + ## One frame of bc16 chrome. `t` / `st` / `mx` / `mt` are the GENERIC + ## timeline keys `chrome_common.js` reads, unchanged, so the clock, the + ## transport and the scrubber are driven by the starter's own code; the + ## `bc16_*` keys are what the APPENDED bc16 game block draws. + let phase = if ended: "gameover" else: "playing" + let points = r16.gamePoints(w) + var node = %*{ + "t": frame, + "st": 0, + "mx": max(1, totalFrames - 1), + "mt": 0, + "sp": view.speed, + "pl": view.playing, + "lp": view.loop, + "sk": view.skipLulls, + "ff": false, + "en": true, + "ph": phase, + "lob": 0, + "pov": -1, + "nim": GameVersion, + "year": "bc16", + "beats": beats, + "game": gameIndex + 1, + "games": doc.games.len, + "map": doc.plan.maps[min(gameIndex, doc.plan.maps.high)], + "round": w.currentRound, + "rounds": doc.plan.maxRounds, + "aliases": [AliasA, AliasB], + "names": [doc.names[0], doc.names[1]], + "sides": [(if sideAslot == 0: "A" else: "B"), + (if sideAslot == 0: "B" else: "A")], + "points": [points[(if sideAslot == 0: 0 else: 1)], + points[(if sideAslot == 0: 1 else: 0)]], + "bc16_archons": bc16Archons(w, sideAslot), + "bc16_horde": bc16Horde(w), + "bc16_econ": bc16Econ(w, sideAslot), + "bc16_units": bc16Units(w, sideAslot), + "bc16_siege": bc16Siege(w, sideAslot), + "gamechips": gameChips, + "doctrines": doctrineWords(doc), + "result": doc.result + } + $node + proc sessionChromeJson*( doc: ReplayDoc, s: Session, view: ViewerState, frame, totalFrames, gameIndex, sideAslot: int, @@ -1576,3 +1892,6 @@ proc sessionChromeJson*( of yBc22: bc22ChromeJson(doc, s.w22, view, frame, totalFrames, gameIndex, sideAslot, beats, gameChips, ended) + of yBc16: + bc16ChromeJson(doc, s.w16, view, frame, totalFrames, gameIndex, sideAslot, + beats, gameChips, ended) diff --git a/src/battlecode/decide.nim b/src/battlecode/decide.nim index 27d36b0..7ad679c 100644 --- a/src/battlecode/decide.nim +++ b/src/battlecode/decide.nim @@ -60,7 +60,7 @@ proc chassisForSeat*(year: string, seat: SeatPolicy): ScriptedChassis = proc chassisNameFor*(year: string, seat: SeatPolicy, sheet: Sheet): string = case yearIdOf(year) - of yBc20, yBc21, yBc22, yBc24, yBc25, yBc23: + of yBc20, yBc21, yBc22, yBc24, yBc25, yBc23, yBc16: (if seat.isLlm: $strongChassisFor(year) else: baselineName(baselineForSeat(year, seat))) of yBc26: $sheet.doctrine.chassis @@ -606,6 +606,113 @@ droids: a faction with one archon and nothing else plays on to round 2000 earning 2 lead a round. """ +const Bc16Preamble* = """ +You command a faction of robots in Battlecode 2016, "Zombie Invasion": a +two-faction grid war on a symmetric map, 3000 rounds a game (numbered 0 to +2999), best of three. + +You do not move a single robot. Before the war you write ONE DOCTRINE — a +JSON sheet of eleven named knobs — and a deterministic simulation then plays +the whole match from it while you watch. + +THE WORLD +- Each faction starts with 1 to 4 ARCHONS (1000 hp) and 300 PARTS. AN ARCHON + CANNOT BE BUILT AND IS THE ONLY THING THAT DECIDES THE GAME: lose your last + one and you lose immediately. +- An archon builds SOLDIERS (30 parts, 60 hp, 4 damage at range-squared 13), + GUARDS (30, 145 hp, 1.5 melee but DOUBLE against zombies and 4 damage + BLOCKED off any hit above 10), SCOUTS (25, 80 hp, NO attack, IGNORES + RUBBLE, sight range-squared 53), VIPERS (120, 120 hp, 2 damage at + range-squared 20 that INFECTS FOR 20 TURNS) and TURRETS (130, 100 hp, 13 + damage between range-squared 6 and 40, immobile — it must PACK into a TTM + to move and UNPACK to shoot). Building freezes the archon for that unit's + build turns: 20 for a scout, 12 a soldier, 10 a guard, 30 a viper, 25 a + turret. +- An archon also REPAIRS one friendly non-archon for 1 hp a turn, for free, + within range-squared 24 — the only healing in the game — and PICKS UP EVERY + PART on any square it stands on or walks onto, all of it, and nothing else + in the game collects parts. +- Income is `max(0, 2 - 0.01 * your live robot count)` parts per round: ZERO + AT 200 ROBOTS and half at 100. That is the whole economy alongside the + map's parts and 200 per zombie den killed. + +THE HORDE +- Each map ships a fixed PUBLIC zombie spawn schedule — round to counts of + STANDARDZOMBIE / RANGEDZOMBIE / FASTZOMBIE / BIGZOMBIE — divided evenly + among the map's 2 to 12 ZOMBIE DENS (2000 hp each, worth 200 parts to + whoever kills one). You can read the whole schedule from round 0. +- Zombies belong to a third team, SEE THE WHOLE MAP ALWAYS, and every zombie + every turn walks at the NEAREST PLAYER-CONTROLLED ROBOT ON THE MAP, OF + EITHER FACTION, and hits it. +- Every 300 rounds the OUTBREAK LEVEL rises and every zombie spawned after it + is stronger: x1.0, x1.1, x1.2, x1.3, x1.5, x1.7, x2.0, x2.3, x2.6, x3.0 — + so a round-2700 BIGZOMBIE has 5000 health and 250 damage. +- A den that still has zombies queued damages EVERY adjacent non-zombie robot + for 10 a round. + +INFECTION, RUBBLE, NEUTRALS +- A zombie hit infects for 10 turns (no damage); a VIPER hit for 20 turns at + 2 damage a turn. ANYTHING THAT DIES WHILE INFECTED LEAVES NO RUBBLE AND + STANDS BACK UP AS A ZOMBIE of its own type on the horde's team, where it + fell: archon to BIGZOMBIE, scout to FASTZOMBIE, soldier or guard to + STANDARDZOMBIE, viper/turret/TTM to RANGEDZOMBIE. It then hunts whoever is + nearest — which can be them. +- Anything that dies UNINFECTED raises the rubble on its square by its own + max health (1000 an archon, 500 a bigzombie, 145 a guard; a third of that + if a TURRET landed the killing blow). RUBBLE OF 100 OR MORE IS IMPASSABLE + to everything except a SCOUT, a FASTZOMBIE and a BIGZOMBIE; 50 or more + DOUBLES every movement and cooldown charge. One clear action turns r into + max(0, 0.95r - 10), so 100 takes fourteen actions and 1000 takes about 55. + A TURRET and a TTM cannot clear. +- NEUTRAL robots stand on most maps. An ARCHON ACTIVATES one within + range-squared 2 for ZERO PARTS and 2 core delay: the neutral is replaced by + an identical robot on your team, immediately active. Some maps place + neutral ARCHONS, and an extra archon is the first tiebreak at round 2999. + +FRIENDLY FIRE IS LEGAL and there is no reading under which shooting your own +soldiers is a strategy; the chassis never does it. + +YOUR REPLY +Reply with ONE JSON object and NOTHING else. Your reply must begin with '{'. +{"sheet": {...knobs...}, "notes": "<=280 chars", "motto": "<=48 chars"} + +THE KNOBS (unknown key, wrong type or out-of-range value = that field's +default; the four integers CLAMP to their range; you cannot forfeit by +answering badly, only by answering weakly): + opening "turtle" | "soldier_viper_aggro" | "scout_zombie_pull" + default "turtle" + turret_count 0..12 default 3 + guard_ratio 0..100 (percent of the ATTACKER budget) default 45 + zombie_kiting "never" | "ranged_only" | "always" default "ranged_only" + den_clear_round 1..2800 default 900 + parts_priority "units" | "turrets" | "vipers" default "units" + archon_spread "huddle" | "spread" | "split" default "spread" + neutral_activation "never" | "opportunistic" | "hunt" default "opportunistic" + retreat_hp 0..100 (percent of max health) default 35 + rubble_clear "never" | "paths" | "aggressive" default "paths" + infection_policy "ignore" | "quarantine" | "suicide_squad" + default "quarantine" + +THE CHASSIS IS NOT YOURS TO CHOOSE. There is no `chassis` knob, and a reply +that sends one has it recorded as an unknown field and ignored. Your faction +is driven by the `bulwark` chassis, which independently of every knob keeps at +least one archon collecting parts, builds an attacker whenever parts allow and +the attacker census is short (never fewer than three attackers per archon), +answers any hostile sensed within range-squared 24 of one of its own archons, +spends every archon's free repair every turn, never walks its last archon +into a den's damage ring, and never fires on its own units. + +HOW A GAME ENDS +A game ends the instant a faction's last ARCHON dies (`archons_destroyed`), +or at the end of round 2999 on this ladder, first non-zero difference wins: +more archons alive (`more_archons`), then greater total live-archon health +(`more_archon_health`), then greater parts stockpile plus the parts cost of +every live robot (`more_parts_net_worth`), then higher maximum live archon id +(`highest_id`, and Clan Basil on a 0-0). THERE IS NO ELIMINATION FOR LOSING +YOUR ARMY: a faction with one archon and nothing else plays on to round 2999 +earning 2 parts a round. +""" + proc preambleFor*(year: string): string = case yearIdOf(year) of yBc20: Bc20Preamble @@ -614,6 +721,7 @@ proc preambleFor*(year: string): string = of yBc25: Bc25Preamble of yBc23: Bc23Preamble of yBc22: Bc22Preamble + of yBc16: Bc16Preamble of yBc26: SystemPreamble proc briefFor*( @@ -1003,6 +1111,129 @@ proc briefFor*( "league ranks by ELO on match wins and results.scores is " & "dominated by the win bonus" } + of yBc16: + payload["economy"] = %*{ + "start_per_team": {"parts": 300}, + "income_per_team_per_round": + "max(0, 2 - 0.01 * your live robot count) parts -- so income is " & + "ZERO at 200 robots and half at 100", + "den_bounty": 200, + "map_parts": "archon-collected only, whole-square, never regenerating" + } + payload["units"] = %*{ + "archon": {"parts": "cannot be built", "hp": 1000, "attack": 0, + "repair_r2": 24, "sight_r2": 35, "move_delay": 2, + "cooldown_delay": 1, "turns_into": "bigzombie", + "does": "builds SCOUT/SOLDIER/GUARD/VIPER/TURRET in an adjacent " & + "square (and is FROZEN for that unit's build turns); " & + "repairs one friendly non-archon for 1 hp within r2<=24 FOR " & + "FREE, once a turn; activates NEUTRALs within r2<=2; " & + "collects parts by standing on them. LOSE YOUR LAST ARCHON " & + "AND YOU LOSE IMMEDIATELY"}, + "scout": {"parts": 25, "build_turns": 20, "hp": 80, "attack": 0, + "sight_r2": 53, "move_delay": 1.4, "ignores_rubble": true, + "turns_into": "fastzombie", + "does": "sees further than anything else, walks through ANY rubble, " & + "cannot attack, and is the cheapest thing you can put " & + "between a den and yourself"}, + "soldier": {"parts": 30, "build_turns": 12, "hp": 60, "attack": 4, + "attack_r2": 13, "sight_r2": 24, "move_delay": 2, "attack_delay": 2, + "turns_into": "standardzombie"}, + "guard": {"parts": 30, "build_turns": 10, "hp": 145, "attack": 1.5, + "attack_r2": 2, "sight_r2": 24, "move_delay": 2, "attack_delay": 1, + "turns_into": "standardzombie", + "does": "DOUBLE damage against zombies, and 4 damage BLOCKED off " & + "any hit above 10"}, + "viper": {"parts": 120, "build_turns": 30, "hp": 120, "attack": 2, + "attack_r2": 20, "infect_turns": 20, "move_delay": 2, + "attack_delay": 3, "turns_into": "rangedzombie", + "does": "infects for 20 turns at 2 damage a turn; an infected robot " & + "that dies becomes a ZOMBIE instead of leaving rubble"}, + "turret": {"parts": 130, "build_turns": 25, "hp": 100, "attack": 13, + "attack_r2": 40, "attack_r2_minimum": 6, "immobile": true, + "attack_delay": 3, "cooldown_delay": 3, "turns_into": "rangedzombie", + "does": "the longest reach in the game, but cannot shoot inside r2 " & + "6 and cannot move or clear rubble; PACK it into a TTM (10 " & + "delay on both counters) to relocate, UNPACK to shoot"}, + "ttm": {"parts": "not buildable -- only reachable by PACKING a turret", + "hp": 100, "attack": 0, "move_delay": 2, "cooldown_delay": 2, + "turns_into": "rangedzombie"} + } + payload["zombies"] = %*{ + "team": "a third team called THE HORDE; it never wins and never scores", + "targeting": "EVERY zombie, EVERY turn, walks at the NEAREST " & + "player-controlled robot on the map, of EITHER team; " & + "zombies see the whole map always", + "standardzombie": {"hp": 60, "attack": 2.5, "attack_r2": 2, + "move_delay": 3}, + "rangedzombie": {"hp": 60, "attack": 3, "attack_r2": 13, + "move_delay": 3}, + "fastzombie": {"hp": 80, "attack": 3, "attack_r2": 2, + "move_delay": 1.4, "ignores_rubble": true}, + "bigzombie": {"hp": 500, "attack": 25, "attack_r2": 2, + "move_delay": 4, "ignores_rubble": true}, + "outbreak": "every 300 rounds every NEWLY SPAWNED zombie's health and " & + "damage are multiplied: x1.0, x1.1, x1.2, x1.3, x1.5, " & + "x1.7, x2.0, x2.3, x2.6, x3.0", + "den": {"hp": 2000, "bounty": 200, + "does": "spawns its share of the public schedule into up to 8 " & + "adjacent squares a turn, in a ring starting toward " & + "the nearest initial archon; if it still has a queue " & + "it damages every adjacent non-zombie for 10 and " & + "tries again"} + } + payload["infection"] = %*{ + "zombie_bite": "10 turns, no damage", + "viper_bite": "20 turns, 2 damage a turn", + "on_death": "an INFECTED robot leaves NO rubble and stands back up as " & + "a zombie of its own type's turns_into, on the ZOMBIE " & + "team, at the current outbreak multiplier, on the square " & + "where it fell -- and it then hunts whoever is nearest", + "on_activation": "a NEUTRAL killed by activation leaves no rubble and " & + "never turns" + } + payload["rubble"] = %*{ + "impassable_at": 100, + "doubles_cost_at": 50, + "from_a_corpse": "an UNINFECTED robot's death adds its own MAX HEALTH " & + "to its square (1000 for an archon, 500 for a " & + "bigzombie, 145 for a guard) -- a third of that if a " & + "TURRET landed the killing blow", + "clearing": "one action turns r into max(0, 0.95*r - 10); a TURRET and " & + "a TTM cannot clear; clearing a square at exactly 0 costs " & + "nothing and does nothing" + } + payload["signals"] = %*{ + "basic_per_turn": 5, "message_per_turn": 20, + "message_senders": "ARCHON and SCOUT only", + "cost": "0.05 delay on BOTH counters inside twice your own sight " & + "radius, plus 0.03 per unit beyond it", + "queue": 1000, + "note": "there is no shared array in 2016, and EVERY signal is heard " & + "by the enemy too" + } + payload["win"] = %*{ + "instant": "destroy the enemy's LAST ARCHON", + "at_round_2999": ["more archons alive", + "greater total live-archon health", + "greater parts stockpile plus the parts cost of " & + "every live robot", + "higher maximum live archon id (and Clan Basil on a " & + "0-0)"], + "note": "there is no elimination for losing your army: a faction with " & + "one archon and nothing else plays on to round 2999 earning 2 " & + "parts a round" + } + payload["sheet_schema"] = bc16SheetSchema() + payload["scoring"] = %*{ + "weights": {"archons_share": 64, "archon_health_share": 24, + "parts_net_worth_share": 12}, + "win_bonus_per_game": 200, + "games": plan.maps.len, + "note": "shares are float32; points truncate to an integer; the " & + "league ranks by ELO on match wins and results.scores is " & + "dominated by the win bonus" + } of yBc26: payload["scoring"] = %*{ "cooperation": {"cat_damage": 0.5, "kings": 0.3, "cheese": 0.2}, diff --git a/src/battlecode/render.nim b/src/battlecode/render.nim index c54be7b..7a35f6c 100644 --- a/src/battlecode/render.nim +++ b/src/battlecode/render.nim @@ -12,7 +12,7 @@ ## round. Re-sending the board every round would be a megabyte a frame; the ## diff is a few dozen bytes. -import std/[json, math, os, sequtils, sets, tables] +import std/[json, math, os, sequtils, sets, strutils, tables] import pixie import bitworld/spriteprotocol import sheet @@ -34,6 +34,9 @@ from years/bc23/units as u23 import nil from years/bc22/world as w22 import nil from years/bc22/constants as c22 import nil from years/bc22/units as u22 import nil +from years/bc16/world as w16 import nil +from years/bc16/constants as c16 import nil +from years/bc16/units as u16 import nil const TileSize* = 16 @@ -140,6 +143,21 @@ const Bc22GoldColor = rgba(0xc9, 0xa2, 0x3a, 255) Bc22DeadSquareColor = rgba(0x4a, 0x2c, 0x2c, 255) + ## bc16's rubble heat ramp: SIX steps with HARD BREAKS AT THE TWO + ## THRESHOLDS THAT MATTER — 50, where every movement and cooldown charge + ## DOUBLES, and 100, where the square is impassable to everything but a + ## SCOUT, a FASTZOMBIE and a BIGZOMBIE. Rubble is this year's terrain and a + ## spectator who cannot see it cannot understand why a soldier is standing + ## still, so it is drawn FIRST and the break is deliberately visible. + Bc16Bare = rgba(0x43, 0x3c, 0x30, 255) ## 0 + Bc16Light = rgba(0x39, 0x33, 0x29, 255) ## 1..49 + Bc16Heavy = rgba(0x2c, 0x27, 0x20, 255) ## 50..99 (double cost) + Bc16Impassable = rgba(0x1c, 0x19, 0x15, 255) ## 100..999 + Bc16Wall = rgba(0x12, 0x10, 0x0e, 255) ## 1000..9999 + Bc16Bedrock = rgba(0x07, 0x07, 0x07, 255) ## >= 10 000 + Bc16PartsColor = rgba(0xd8, 0xb0, 0x4a, 255) + Bc16PartsGoneColor = rgba(0x4a, 0x3c, 0x22, 255) + type Atlas = ref object image: Image @@ -1093,6 +1111,112 @@ proc buildBc22Packet(r: Renderer, w: w22.World, gameIndex, sideAslot: int, packet.addSprite(BroadcastChromeSpriteId, 1, 1, [0'u8, 0, 0, 0], chrome) packet +proc bc16UnitSprite(unit: w16.Robot): string = + ## Palette follows the 2016 CLIENT's own four team colours, because the + ## client ships every one of the twelve types at all four `Team` palettes: + ## blue = side A, red = side B, GREEN = THE HORDE and GREY = NEUTRAL. This + ## is the first year in the repo whose art can draw a neutral robot AS + ## ITSELF rather than as a greyed team sprite — which matters, because + ## `neutral_activation` is a headline knob. + let tint = + case unit.team + of u16.teamA: "a_" + of u16.teamB: "b_" + of u16.teamNeutral: "neutral_" + of u16.teamZombie: "horde_" + tint & ($unit.kind).toLowerAscii() + +proc bc16TerrainStage(w: w16.World): int = + ## The rubble layer changes on every corpse and every clear, and the parts + ## layer every time an archon walks over a deposit, so the terrain sprite is + ## re-cut on a fixed cadence: eight rounds is often enough that a corpse + ## bricking a lane is visible as it happens and rare enough that an 80x80 + ## board is not re-rasterised twenty-four times a second. + w.currentRound div 8 + +proc bc16RubbleColour(rubble: float64): ColorRGBA = + if rubble <= 0.0: Bc16Bare + elif rubble < c16.RubbleSlowThresh: Bc16Light + elif rubble < c16.RubbleObstructionThresh: Bc16Heavy + elif rubble < 1000.0: Bc16Impassable + elif rubble < 10_000.0: Bc16Wall + else: Bc16Bedrock + +proc renderBc16Terrain(r: Renderer, w: w16.World): Image = + result = newImage(w.width * TileSize, w.height * TileSize) + result.fill(Bc16Bare) + let ctx = newContext(result) + for y in 0 ..< w.height: + for x in 0 ..< w.width: + let px = x * TileSize + ## 2016's y axis grows SOUTH — `Direction.NORTH` is `(0, -1)` — which + ## is the SAME direction the canvas grows, so unlike every other year + ## in this repo the row is NOT flipped. + let py = y * TileSize + let l = u16.loc(x, y) + let i = w16.idx(w, l) + ctx.fillStyle = bc16RubbleColour(w.rubble[i]) + ctx.fillRect(rect(float32(px), float32(py), + float32(TileSize), float32(TileSize))) + ## Parts: a pip SIZED BY AMOUNT, and a hollow mark the moment an archon + ## takes the square — which is how a spectator sees an + ## `archon_spread: split` faction eating the map. + let parts = w.partsAt[i] + let cx = float32(px + TileSize div 2) + let cy = float32(py + TileSize div 2) + if parts > 0.0: + let size = float32(3 + min(6, int(parts) div 40)) + ctx.fillStyle = Bc16PartsColor + ctx.fillRect(rect(cx - size / 2, cy - size / 2, size, size)) + elif w.map.parts[i] > 0.0: + ctx.fillStyle = Bc16PartsGoneColor + ctx.fillRect(rect(cx - 2.5, cy - 0.75, 5.0, 1.5)) + +proc buildBc16Packet(r: Renderer, w: w16.World, gameIndex, sideAslot: int, + chrome: string): seq[uint8] = + var packet: seq[uint8] + let newGame = r.terrainGame != gameIndex + let stage = bc16TerrainStage(w) + + if newGame: + r.terrainGame = gameIndex + r.terrainStage = -1 + r.liveObjects.clear() + r.prevRobotSprite.clear() + packet.addClearObjects() + packet.addLayer(MapLayerId, MapLayerKind, ZoomableFlag) + packet.addViewport(MapLayerId, w.width * TileSize, w.height * TileSize) + + if r.terrainStage != stage: + r.terrainStage = stage + let terrain = r.renderBc16Terrain(w) + packet.addSprite(TerrainSpriteId, terrain.width, terrain.height, + straightPixels(terrain), "terrain") + packet.addObject(1, 0, 0, -32768, MapLayerId, TerrainSpriteId) + + ## Every live robot. Object ids are stable for a robot's whole life, so the + ## client's motion interpolation glides it between rounds instead of + ## teleporting it. An ARCHON draws above everything else, because it is the + ## only unit whose death ends the game; a DEN draws below everything, + ## because it never moves and everything walks over its ring. + var seen = initHashSet[int]() + for id in w.execOrder: + let unit = w16.robotById(w, id) + if unit == nil: continue + let objectId = RobotObjectBase + (id mod 20000) + seen.incl(objectId) + let sprite = r.spriteId(packet, bc16UnitSprite(unit)) + r.addObj(packet, objectId, unit.loc.x * TileSize, unit.loc.y * TileSize, + (if unit.kind == c16.rtArchon: 6 + elif unit.kind == c16.rtZombieden: 3 + else: 5), sprite) + for objectId in toSeq(r.liveObjects): + if objectId >= RobotObjectBase and objectId notin seen: + r.dropObj(packet, objectId) + + packet.addSprite(BroadcastChromeSpriteId, 1, 1, [0'u8, 0, 0, 0], chrome) + packet + proc buildSessionPacket*(r: Renderer, s: Session, chrome: string): seq[uint8] = ## The ONE place the renderer branches on the year. `Session` is an object ## variant, so the compiler checks that a new year gets an arm here. @@ -1104,3 +1228,4 @@ proc buildSessionPacket*(r: Renderer, s: Session, chrome: string): seq[uint8] = of yBc25: r.buildBc25Packet(s.w25, s.gameIndex, s.sideAslot, chrome) of yBc23: r.buildBc23Packet(s.w23, s.gameIndex, s.sideAslot, chrome) of yBc22: r.buildBc22Packet(s.w22, s.gameIndex, s.sideAslot, chrome) + of yBc16: r.buildBc16Packet(s.w16, s.gameIndex, s.sideAslot, chrome) diff --git a/src/battlecode/rng.nim b/src/battlecode/rng.nim index a7336af..935802e 100644 --- a/src/battlecode/rng.nim +++ b/src/battlecode/rng.nim @@ -130,9 +130,15 @@ proc allocateNextBlock(gen: var IdGenerator) = gen.reserved[i] = a gen.nextIdBlock += IdBlockSize -proc initIdGenerator*(seed: int): IdGenerator = +proc initIdGenerator*(seed: int, firstBlock = MinId): IdGenerator = + ## `new IDGenerator(seed)`. `firstBlock` is `IDGenerator.nextIDBlock`'s + ## starting value, which is NOT the same in every Battlecode year: 2020..2026 + ## start it at the 10 000 floor (the default, so no existing call site + ## changes), while **2016 starts it at 0** and therefore mints ids from 1 — + ## `reservedIDs[i] = nextIDBlock + i + 1`. The block size, the Fisher-Yates + ## shuffle and its `nextInt(i+1)` call order are identical in both. result.random = initJavaRandom(seed) - result.nextIdBlock = MinId + result.nextIdBlock = firstBlock result.allocateNextBlock() proc nextId*(gen: var IdGenerator): int = diff --git a/src/battlecode/sheet.nim b/src/battlecode/sheet.nim index 63ad003..05ee8d1 100644 --- a/src/battlecode/sheet.nim +++ b/src/battlecode/sheet.nim @@ -30,9 +30,10 @@ import years/bc24/knobs as knobs24 import years/bc25/knobs as knobs25 import years/bc23/knobs as knobs23 import years/bc22/knobs as knobs22 +import years/bc16/knobs as knobs16 export sim_types, sheet_common, knobs26, knobs20, knobs21, knobs24, - knobs25, knobs23, knobs22 + knobs25, knobs23, knobs22, knobs16 const YearBc26* = "bc26" @@ -42,6 +43,7 @@ const YearBc25* = "bc25" YearBc23* = "bc23" YearBc22* = "bc22" + YearBc16* = "bc16" type Sheet* = object @@ -53,6 +55,7 @@ type doctrine25*: knobs25.Doctrine25 ## the bc25 knobs; defaults on another year doctrine23*: knobs23.Doctrine23 ## the bc23 knobs; defaults on another year doctrine22*: knobs22.Doctrine22 ## the bc22 knobs; defaults on another year + doctrine16*: knobs16.Doctrine16 ## the bc16 knobs; defaults on another year notes*: string motto*: string defaultsApplied*: seq[string] @@ -72,6 +75,7 @@ proc knownKeysFor*(year: string): seq[string] = of YearBc25: @(knobs25.KnownKeys25) of YearBc23: @(knobs23.KnownKeys23) of YearBc22: @(knobs22.KnownKeys22) + of YearBc16: @(knobs16.KnownKeys16) else: @(knobs26.KnownKeys) proc defaultSheet*(year = YearBc26): Sheet = @@ -82,6 +86,7 @@ proc defaultSheet*(year = YearBc26): Sheet = doctrine25: knobs25.defaultDoctrine25(), doctrine23: knobs23.defaultDoctrine23(), doctrine22: knobs22.defaultDoctrine22(), + doctrine16: knobs16.defaultDoctrine16(), notes: "", motto: "", submitted: "{}", envelope: "") proc validate*(payload: JsonNode, year = YearBc26): Sheet = @@ -175,11 +180,13 @@ proc validate*(payload: JsonNode, year = YearBc26): Sheet = of YearBc23: result.doctrine23 = knobs23.applyKnobs23(seen, result.defaultsApplied) of YearBc22: - ## bc22 ALONE counts an ABSENT known key in `defaultsApplied` (the envelope - ## pin, item 2). Doing it year-neutrally would change what a bc26/bc20/bc21/ - ## bc23/bc24/bc25 episode records in that array, which "prior years' - ## semantics unchanged" forbids. + ## bc22 and bc16 ALONE count an ABSENT known key in `defaultsApplied` (the + ## envelope pin, item 2). Doing it year-neutrally would change what a + ## bc26/bc20/bc21/bc23/bc24/bc25 episode records in that array, which + ## "prior years' semantics unchanged" forbids. result.doctrine22 = knobs22.applyKnobs22(seen, result.defaultsApplied) + of YearBc16: + result.doctrine16 = knobs16.applyKnobs16(seen, result.defaultsApplied) else: result.doctrine = knobs26.applyKnobs(seen, result.defaultsApplied) @@ -204,6 +211,7 @@ proc toJson*(sheet: Sheet): JsonNode = of YearBc25: knobs25.toJson25(sheet.doctrine25) of YearBc23: knobs23.toJson23(sheet.doctrine23) of YearBc22: knobs22.toJson22(sheet.doctrine22) + of YearBc16: knobs16.toJson16(sheet.doctrine16) else: knobs26.toJson(sheet.doctrine) proc plainWords*(sheet: Sheet): seq[string] = @@ -216,4 +224,5 @@ proc plainWords*(sheet: Sheet): seq[string] = of YearBc25: knobs25.plainWords25(sheet.doctrine25) of YearBc23: knobs23.plainWords23(sheet.doctrine23) of YearBc22: knobs22.plainWords22(sheet.doctrine22) + of YearBc16: knobs16.plainWords16(sheet.doctrine16) else: knobs26.plainWords(sheet.doctrine) diff --git a/src/battlecode/sim_types.nim b/src/battlecode/sim_types.nim index de94c3e..b4af5e8 100644 --- a/src/battlecode/sim_types.nim +++ b/src/battlecode/sim_types.nim @@ -13,13 +13,43 @@ import std/[strutils, unicode] const GameName* = "battlecode" - GameVersion* = "GV10" + GameVersion* = "GV11" ## PREPEND-ONLY CHANGELOG. Anything that changes what a policy sees, how a ## seat is scored, or how a round resolves bumps this in the SAME commit, ## and `tools/ci/check_gameversion.sh` compares the headline (not the ## digits) against the base branch — a number alone cannot detect two ## branches claiming the same version for different rules. ## + ## GV11 — the `bc16` year module: Battlecode 2016 "Zombie Invasion" + ## ported from battlecode-server-2016 at commit 11a0b09f (oracle + ## jar 2016.0.2.2; THE OFFICIAL 2016 SPEC IS LOST and this year's + ## `GameConstants` has no `SPEC_VERSION` at all, so the jar is + ## pinned by sha256 AND size and the ENGINE SOURCE IS THE SPEC): + ## the four-step round loop over an INSERTION-ordered exec list + ## with by-value removal and a pre-sweep snapshot, ROUNDS NUMBERED + ## FROM ZERO (`currentRound` starts at -1, so the last round is + ## 2999), the twelve robot types with their float64 core/weapon + ## delay pair and its ASYMMETRIC set-up-to/add-to charging + ## (`activateCoreAction` sets the weapon and adds the core; + ## `activateAttack` adds the weapon and sets the core), the rubble + ## economy (impassable at 100, double cost at 50, `0.95r - 10` per + ## clear, and every UNINFECTED corpse adding its own max health, + ## a third of it on a turret kill), parts income + ## `max(0, 2 - 0.01 * robots)` with ARCHON-ONLY whole-square + ## pickup, the public per-den zombie spawn schedule with its + ## BUILD-TIME symmetric split, the outbreak ladder applied at the + ## moment a zombie spawns, the verbatim zombie AI over THREE + ## independent `Random(mapSeed)` streams, infection with its + ## 10/20-turn counters and its die-and-turn conversion, free + ## neutral activation, and the four-rung round-2999 tiebreak + ## ladder, behind `game_config.year`. The bytecode-dependent delay + ## decay is PINNED TO 1.0 (a documented divergence, V1). + ## bc20, bc21, bc22, bc23, bc24, bc25 AND bc26 SEMANTICS ARE + ## UNCHANGED: no GV04..GV10 recording carries a byte whose meaning + ## changed — this run makes no year-neutral behaviour change at all + ## — which is why `ReplayCompatibleGameVersions` is EXTENDED rather + ## than reset and every hosted replay keeps rendering. + ## ## GV10 — the `bc22` year module: Battlecode 2022 "Mutation" ported ## from battlecode22 at commit 6ed05b67 (oracle jar 2.2.1, whose ## own `SPEC_VERSION` really is "2.2.1", so the string is a second @@ -188,7 +218,7 @@ const ## formation, squeaks, cats, backstab, float32-narrowed scoring. ReplayCompatibleGameVersions* = ["GV04", "GV05", "GV06", "GV07", "GV08", - "GV09", GameVersion] + "GV09", "GV10", GameVersion] ## Versions whose recordings this build can still re-derive. A replay ## carrying anything else is refused with a readable message rather than ## silently re-simulated under different rules. @@ -236,6 +266,8 @@ type scExamplefuncsplayer23 = "examplefuncsplayer23" scWololo = "wololo" scExamplefuncsplayer22 = "examplefuncsplayer22" + scBulwark = "bulwark" + scGreenhorn = "greenhorn" ConfigError* = object of CatchableError ## An unusable `game_config`. The container exits 2 on this, per ctf. diff --git a/src/battlecode/years/bc16/chassis/archon.nim b/src/battlecode/years/bc16/chassis/archon.nim new file mode 100644 index 0000000..21851fb --- /dev/null +++ b/src/battlecode/years/bc16/chassis/archon.nim @@ -0,0 +1,172 @@ +## `bulwark`'s archon turn — in the ENGINE'S OWN ACTION ORDER, because an +## archon is the only thing that decides this game. +## +## An ARCHON is 1000 HP, cannot be built, and is the only repair source (1 HP +## a turn, free, r2 <= 24, once a turn), the only parts collector (whole +## square, by standing on it or moving onto it) and the only activator of +## NEUTRALs (r2 <= 2, free, 2 core delay). LOSE YOUR LAST ONE AND YOU LOSE ON +## THE SPOT. +## +## The turn, in order: +## +## 1. **spend the free repair** on the weakest damaged friendly non-archon in +## r2 <= 24 — it costs NO delay of either kind, so there is never a reason +## not to; +## 2. **activate** a neutral already in reach (`neutral.nim`), which is a free +## unit for two core delay; +## 3. **build** what `econ.nim queue()` asks for, into the free adjacent +## square nearest the frontier, never boxing itself in and NEVER into a +## square adjacent to a den that has zombies queued; +## 4. **posture** per `archon_spread`, walking over parts squares wherever the +## route allows because a move onto parts collects them for nothing. +## +## The unconditional floors, at every knob setting: a build order whenever the +## stockpile is above 200, and the LAST archon never steps into a den's +## damage ring. + +import ../world +import kit, econ, neutral, comms + +export kit + +proc repairSomebody(w: World, s: Side, r: Robot): bool {.discardable.} = + if w.brokenChassis: return false ## the negative control never repairs + if r.repairCount >= 1: return false + var bestAt = loc(-1, -1) + var worst = 1.0e18 + for other in w.senseNearbyRobots(r, 24): + if not r.spend(1): break + if other.team != r.team: continue + if other.kind == rtArchon: continue + if other.health >= other.maxHealth: continue + if not w.canRepair(r, other.loc): continue + let deficit = other.health + if deficit < worst: + worst = deficit + bestAt = other.loc + if bestAt.x < 0: return false + w.doRepair(r, bestAt) + +proc buildSite(w: World, s: Side, r: Robot, kind: RobotType): Dir = + ## The free adjacent square nearest the frontier that the new unit can + ## actually stand on, and that is not in a den's damage ring. + result = dNone + var bestScore = -1.0e18 + for d in MoveDirs: + if not r.spend(1): break + if not w.canBuild(r, d, kind): continue + let at = r.loc + d + if nearDenWithQueue(w, at): continue + var score = -w.getRubble(at) / 10.0 + if s.frontier.x >= 0: + score -= float64(at.distanceSquaredTo(s.frontier)) / 10.0 + if score > bestScore: + bestScore = score + result = d + +proc buildSomething(w: World, s: Side, r: Robot): bool {.discardable.} = + if not r.d.isCoreReady(): return false + for kind in buildQueue(w, s): + if not canAfford(w, s, kind): continue + let d = buildSite(w, s, r, kind) + if d == dNone: continue + if w.doBuild(r, d, kind): + s.commit(kind) + return true + false + +proc postureTarget(w: World, s: Side, r: Robot): Loc = + ## `posture()` per `archon_spread`, and the parts walk that pays for it. + ## + ## `huddle`: keep every archon inside r2 <= 24 of another so their repair + ## fields overlap. `spread`: hold r2 ~ 50-100 apart, each with its own + ## screen. `split`: send one archon away to farm the far parts squares and + ## the far neutrals while the rest hold — which the measured maps reward + ## hard (`quadrants` has 20 520 parts over 684 squares; `turtle` has 1 800 + ## on SIX). + result = loc(-1, -1) + ## A neutral in reach outranks everything: it is a free unit. + let neutralAt = neutral.target(w, s, r) + if neutralAt.x >= 0: return neutralAt + ## Then the nearest parts square inside vision, because a move onto parts + ## collects the whole square for nothing. + var bestParts = loc(-1, -1) + var bestScore = -1.0e18 + for l in w.locationsWithinRadiusSquared(r.loc, r.kind.sightRadiusSquared()): + if not r.spend(1): break + if w.getParts(l) <= 0.0: continue + if rubbleBlocks(w.getRubble(l), r.kind): continue + let score = w.getParts(l) - float64(l.distanceSquaredTo(r.loc)) + if score > bestScore: + bestScore = score + bestParts = l + if bestParts.x >= 0: return bestParts + ## Nothing in sight: walk at the nearest REMEMBERED parts square. Measured + ## on `caverns` (1 078 of 1 892 squares impassable, 110 parts squares) a + ## sight-radius-only archon collected ZERO parts in 1 350 rounds, because + ## every deposit is outside r2 35 of its opening square. + let remembered = nearestPartsTarget(w, s, r.loc) + if remembered.x >= 0: return remembered + case s.doctrine.archonSpread + of asHuddle: + ## Close on the nearest other archon until the repair fields overlap. + var nearest = loc(-1, -1) + var best = high(int) + for l in s.archons: + if l == r.loc: continue + let d = l.distanceSquaredTo(r.loc) + if d < best: + best = d + nearest = l + if nearest.x >= 0 and best > 24: return nearest + result = loc(-1, -1) + of asSpread: + ## Open up if we are inside r2 50 of another archon. + for l in s.archons: + if l == r.loc: continue + if l.distanceSquaredTo(r.loc) < 50: + return loc(r.loc.x + (r.loc.x - l.x), r.loc.y + (r.loc.y - l.y)) + result = loc(-1, -1) + of asSplit: + ## The FIRST archon in the census farms the far half of the map; the rest + ## hold. "Far" is the enemy-facing side of our own centroid, which is + ## where the unclaimed parts and neutrals are. + if s.archons.len > 1 and s.archons[0] == r.loc and s.frontier.x >= 0: + return s.frontier + result = loc(-1, -1) + +proc threatStep(w: World, s: Side, r: Robot): bool {.discardable.} = + ## An ARCHON HAS NO ATTACK. Standing next to a BIGZOMBIE is 25 damage a + ## round (75 at outbreak level 9) against the only unit that decides the + ## game, so an archon that is in contact with something that can shoot it + ## steps away first and builds afterwards. + if not r.d.isCoreReady(): return false + var threat = loc(-1, -1) + var best = high(int) + for other in w.senseHostileRobots(r, 8): + if not r.spend(1): break + if not canAttack(other.kind): continue + let d = other.loc.distanceSquaredTo(r.loc) + if d < best: + best = d + threat = other.loc + if threat.x < 0: return false + if best > 4: return false + w.stepToward(r, threat, away = true) + +proc runArchon*(w: World, s: Side, r: Robot) = + discard repairSomebody(w, s, r) + discard activateAdjacent(w, s, r) + discard buildSomething(w, s, r) + broadcastRally(w, s, r) + if not r.d.isCoreReady(): return + if threatStep(w, s, r): return + let target = postureTarget(w, s, r) + if target.x < 0: return + ## THE LAST ARCHON NEVER STEPS INTO A DEN'S DAMAGE RING, at any setting. + if s.archons.len <= 1: + var safe = true + for d in MoveDirs: + if r.loc + d == target and nearDenWithQueue(w, target): safe = false + if not safe: return + discard w.stepToward(r, target) diff --git a/src/battlecode/years/bc16/chassis/bulwark.nim b/src/battlecode/years/bc16/chassis/bulwark.nim new file mode 100644 index 0000000..f7f41b4 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/bulwark.nim @@ -0,0 +1,137 @@ +## `bulwark` — the strong baseline and the champion chassis: the turn +## dispatcher. +## +## Written for this run from the ENGINE's own mechanics and from the three +## archetypes the run's idea text itself names (turret turtle, aggressive +## soldier/viper, scout zombie-pull). **`TheDuck314/battlecode2016` and +## `bshimanuki/battlecode2016` carry NO LICENCE, were not cloned, not read, +## not copied, not vendored, not compiled and not translated, and contribute +## nothing.** Where this repo's documents say "the play the 2016 meta made", +## that is a statement about the idea's own characterisation of the 2016 +## finals, not a claim about any repository's contents. +## +## Parameterised by all eleven knobs, and NO KNOB CAN MAKE IT INERT: the +## floors live in `econ.nim` (three attackers per archon, a build order above +## 200 parts), `archon.nim` (the free repair every turn, the last archon out +## of a den's ring) and `combat.nim` (always take a kill, never friendly +## fire). + +import ../world +import kit, econ, combat, micro, infect, rubble, neutral, turret, dens, comms +import archon as archonModule + +export kit + +proc beginRound*(w: World, s: Side) = + ## The chassis's round-level bookkeeping, run BEFORE the exec sweep so every + ## robot this round reads the same census and the same den programme. + w.refreshCensus(s) + s.claimedNeutrals.setLen(0) + dens.schedule(w, s) + +proc objectiveFor(w: World, s: Side, r: Robot): Loc = + ## Where a fighting unit wants to be when it has nothing to shoot. + ## + ## THE DEFENSIVE FLOOR COMES FIRST, at every knob setting and in every + ## opening: a hostile inside r2 64 of one of our archons is answered before + ## any plan, because the only thing that ends this game is an archon dying. + ## Measured on `caverns` (1 078 impassable squares, two archons a side): + ## without this, a mirror match ended in `archons_destroyed` at round 756 + ## with 44 of the loser's own units standing back up as zombies inside its + ## own ring. + let threat = s.nearestThreat(r.loc) + if threat.x >= 0: + let home = s.nearestArchon(r.loc) + if home.x < 0 or home.distanceSquaredTo(threat) <= 36: + return threat + if s.hasDenTarget: + let approach = denApproachSquare(w, s, r) + if approach.x >= 0: return approach + case s.doctrine.opening + of opSoldierViperAggro: + let enemy = s.nearestEnemyArchon(r.loc) + if enemy.x >= 0: return enemy + of opTurtle: + ## Hold the ring, AND FACE THE DEN. A turtle that wanders is not a + ## turtle: a unit beyond r2 36 of its nearest archon walks back. Inside + ## that radius it stands between the archon and THE NEAREST DEN, not the + ## enemy — because the horde is what arrives on a schedule and every + ## zombie walks at the NEAREST player-controlled robot, so the wall has + ## to be on the horde's side of the archon or the archon IS the wall. + let home = s.nearestArchon(r.loc) + if home.x >= 0: + if home.distanceSquaredTo(r.loc) > 36: + return home + var face = s.frontier + var best = high(int) + for den in w.liveDens(): + let d = den.loc.distanceSquaredTo(home) + if d < best: + best = d + face = den.loc + if face.x >= 0: + return loc((home.x * 2 + face.x) div 3, (home.y * 2 + face.y) div 3) + of opScoutZombiePull: + if s.frontier.x >= 0: return s.frontier + if s.frontier.x >= 0: return s.frontier + loc(-1, -1) + +proc runFighter(w: World, s: Side, r: Robot) = + ## SOLDIER, GUARD and VIPER. The order is: shoot what is worth shooting, + ## then honour the infection policy, then retreat, then kite, then walk at + ## the objective. A GUARD closes to r2 <= 2 and holds, because it is the + ## block. + let pick = pickTarget(w, s, r, s.denCommitted) + if pick.ok: + w.doAttack(r, pick.at) + if infect.plan(w, s, r): return + if micro.retreat(w, s, r): return + if micro.kite(w, s, r): return + if rubble.plan(w, s, r): return + let objective = objectiveFor(w, s, r) + if objective.x >= 0: + if w.stepToward(r, objective): return + discard w.stepAnywhere(r) + +proc runScout(w: World, s: Side, r: Robot) = + ## A SCOUT costs 25 parts, has 80 health, sees r2 <= 53, IGNORES RUBBLE + ## ENTIRELY and cannot attack — so it is the cheapest legal bait in the + ## game, and the cheapest digger (movementDelay 1.4). + scoutPing(w, s, r) + if infect.plan(w, s, r): return + if s.doctrine.opening == opScoutZombiePull: + ## Stand on the FAR side of the nearest den — the side away from our own + ## archons — so that the nearest player-controlled robot to that den is + ## OURS and standing where the wave walks at us and then at them. + ## `getNearestPlayerControlled` is the whole zombie targeting rule. + var bestDen = loc(-1, -1) + var best = high(int) + for den in w.liveDens(): + let d = den.loc.distanceSquaredTo(r.loc) + if d < best: + best = d + bestDen = den.loc + if bestDen.x >= 0: + let home = s.nearestArchon(r.loc) + var bait = bestDen + if home.x >= 0: + bait = loc(bestDen.x + (bestDen.x - home.x) div 4, + bestDen.y + (bestDen.y - home.y) div 4) + if not w.onTheMap(bait): bait = bestDen + if bait.distanceSquaredTo(r.loc) > 4: + if w.stepToward(r, bait): return + return + if rubble.plan(w, s, r): return + let enemy = s.nearestEnemyArchon(r.loc) + if enemy.x >= 0 and w.stepToward(r, enemy): return + discard w.stepAnywhere(r) + +proc runBulwark*(w: World, s: Side, r: Robot) = + drainQueue(w, s, r) + case r.kind + of rtArchon: archonModule.runArchon(w, s, r) + of rtScout: runScout(w, s, r) + of rtSoldier, rtGuard, rtViper: runFighter(w, s, r) + of rtTurret: runTurret(w, s, r) + of rtTtm: runTtm(w, s, r) + else: discard diff --git a/src/battlecode/years/bc16/chassis/combat.nim b/src/battlecode/years/bc16/chassis/combat.nim new file mode 100644 index 0000000..f85bca6 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/combat.nim @@ -0,0 +1,69 @@ +## `bulwark`'s target selection — the war's "what do I shoot" half. +## +## Priority, in order, and it is the same for every unit that can attack: +## +## 1. a target THIS ATTACK WILL KILL (refusing a free kill is not a strategy, +## and it is what makes `retreat_hp` and `zombie_kiting` safe at every +## setting); +## 2. a ZOMBIEDEN inside range, but only once `dens.nim` has committed; +## 3. the lowest-health hostile, with ties broken by threat: vipers first +## (a 20-turn infection is 40 damage and an enemy zombie), then soldiers, +## then guards, then unpacked turrets, then zombies by descending damage. +## +## **IT NEVER SELECTS A FRIENDLY SQUARE.** Friendly fire is legal in 2016 +## (rule 3.2.3: there is no team check on the attack path at all) and this +## chassis never uses it; `tests/test_bc16_baselines.nim` asserts that over +## whole games. + +import ../world +import kit + +export kit + +func threatRank*(k: RobotType): int = + ## Higher is more urgent. + case k + of rtViper: 90 + of rtSoldier: 80 + of rtTurret: 75 + of rtGuard: 60 + of rtBigzombie: 55 + of rtFastzombie: 50 + of rtRangedzombie: 45 + of rtStandardzombie: 40 + of rtTtm: 30 + of rtScout: 20 + of rtArchon: 70 + of rtZombieden: 10 + +proc pickTarget*(w: World, s: Side, r: Robot, + denCommitted: bool): tuple[ok: bool, at: Loc] = + ## One pass over the hostiles this robot can sense, in INSERTION ORDER — + ## which is the order the engine returns them in, so two identical + ## situations resolve identically. + result = (ok: false, at: loc(-1, -1)) + if not canAttack(r.kind): return + if not r.d.isWeaponReady(): return + var bestScore = -1.0e18 + for other in w.senseHostileRobots(r, -1): + if not r.spend(1): break + if not w.canAttackLocation(r, other.loc): continue + let rate = guardRate(r.kind, other.kind) + let dealt = damageToTarget(r.attackPower * rate, other.kind) + var score = float64(threatRank(other.kind)) + ## A GUARD deals DOUBLE damage to a zombie and blocks 4 off any hit above + ## 10, so it is the anti-horde unit and ranks the horde first. + if r.kind == rtGuard and other.team == teamZombie: score += 60.0 + ## A kill dominates everything below it. + if dealt >= other.health: score += 10_000.0 + if other.kind == rtZombieden: + if denCommitted: score += 200.0 else: score -= 5_000.0 + ## Among survivors, prefer the one closest to death. + score += max(0.0, 400.0 - other.health) + if score > bestScore: + bestScore = score + result = (ok: true, at: other.loc) + if result.ok and bestScore <= -1000.0: + ## Only an uncommitted den was in range: hold fire rather than waking it + ## for nothing. + result = (ok: false, at: loc(-1, -1)) diff --git a/src/battlecode/years/bc16/chassis/comms.nim b/src/battlecode/years/bc16/chassis/comms.nim new file mode 100644 index 0000000..b314174 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/comms.nim @@ -0,0 +1,89 @@ +## `bulwark`'s signal layer — and the one place 2016 is harder than every +## other year this repo ships: THERE IS NO SHARED ARRAY. +## +## A robot may send 5 BASIC signals a turn (position + id + team, any type) +## and an ARCHON or SCOUT may send 20 MESSAGE signals (two 32-bit ints), each +## costing 0.05 on BOTH counters inside twice its own sight radius and +## `0.05 + 0.03 * (r2/sightR2 - 2)` beyond it. **AND EVERY SIGNAL IS HEARD BY +## THE ENEMY TOO**, which is why this chassis encodes only what it is willing +## to disclose. +## +## The layout: word 1 packs `kind:4 | x:7 | y:7 | payload:14`, word 2 packs +## `round:12 | value:20`. Kinds: 0 den-sighting, 1 den-health, +## 2 neutral-sighting, 3 neutral-claim, 4 enemy-archon-sighting, 5 rally, +## 6 census, 7 clear-request, 8 quarantine-lane, 9 suicide-target. +## +## The chassis sends AT MOST one message signal per archon per turn and one +## basic signal per scout per five turns, at `r2 = 2 * sightR2` so the cost is +## the flat 0.05, and it never encodes anything whose disclosure to the enemy +## costs more than the coordination is worth: den health and rally points yes, +## archon posture no. +## +## A doctrine cannot redefine this layout, cannot set the radius and cannot +## add a kind (§Out of scope): in this year every signal is heard by the +## enemy, so exposing the layout would be exposing a channel a doctrine could +## use to leak information it should not have. + +import ../world, ../signals +import kit + +export kit + +const + KindDenSighting* = 0 + KindDenHealth* = 1 + KindNeutralSighting* = 2 + KindNeutralClaim* = 3 + KindEnemyArchonSighting* = 4 + KindRally* = 5 + KindCensus* = 6 + KindClearRequest* = 7 + KindQuarantineLane* = 8 + KindSuicideTarget* = 9 + +func packWord1*(kind: int, l: Loc, payload: int): int = + ((kind and 0xF) shl 28) or ((l.x and 0x7F) shl 21) or + ((l.y and 0x7F) shl 14) or (payload and 0x3FFF) + +func packWord2*(round, value: int): int = + ((round and 0xFFF) shl 20) or (value and 0xFFFFF) + +func unpackKind*(word1: int): int = (word1 shr 28) and 0xF +func unpackLoc*(word1: int): Loc = + loc((word1 shr 21) and 0x7F, (word1 shr 14) and 0x7F) +func unpackPayload*(word1: int): int = word1 and 0x3FFF + +proc broadcastRally*(w: World, s: Side, r: Robot) = + ## One message signal per archon per turn, at exactly twice its own sight + ## radius so the charge is the flat 0.05 on both counters. + if not canMessageSignal(r.kind): return + if r.messageSignalCount >= 1: return + if not r.spend(2): return + let radius = 2 * r.kind.sightRadiusSquared() + let rally = if s.hasDenTarget: s.denTarget else: s.frontier + if rally.x < 0: return + w.doBroadcastMessage(r, packWord1(KindRally, rally, s.attackers), + packWord2(w.currentRound, s.counts[rtGuard]), radius) + +proc scoutPing*(w: World, s: Side, r: Robot) = + ## One basic signal per scout per five rounds: a position ping, which is + ## all a basic signal carries anyway. + if r.kind != rtScout: return + if (w.currentRound mod 5) != 0: return + if r.basicSignalCount >= 1: return + if not r.spend(1): return + w.doBroadcast(r, 2 * r.kind.sightRadiusSquared()) + +proc drainQueue*(w: World, s: Side, r: Robot) = + ## Read the queue so it cannot overflow the 1000-entry cap, and take the + ## rally point out of it. One credit per signal read. + while r.signalQueue.len > 0: + if not r.spend(1): break + let got = r.readSignal() + if not got.ok: break + if not got.signal.hasMessage: continue + if unpackKind(got.signal.m1) == KindRally: + let at = unpackLoc(got.signal.m1) + if at.x >= 0 and w.onTheMap(at): + r.taskLoc = at + r.hasTask = true diff --git a/src/battlecode/years/bc16/chassis/dens.nim b/src/battlecode/years/bc16/chassis/dens.nim new file mode 100644 index 0000000..bdd3cd7 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/dens.nim @@ -0,0 +1,70 @@ +## `bulwark`'s `dens.nim schedule()` — when the faction commits a strike group +## to killing a ZOMBIEDEN, and which one. +## +## A den is 2000 HP and pays 200 parts on death, and killing it deletes that +## den's share of EVERY FUTURE WAVE for the rest of the game (measured: a +## played-pool den holds 51 to 138 queued zombies across a game). But 2000 HP +## at a soldier's 4 damage is FIVE HUNDRED attacks, and a den damages every +## adjacent non-zombie for 10 a round whenever it has a queue. Early is a real +## investment; late is a real concession — which is exactly what +## `den_clear_round` is asking the cog to decide. +## +## The target is the den with the largest remaining queue and the cheapest +## approach, and every member of the strike group is kept OUT of the eight +## adjacent squares except while attacking. + +import ../world +import kit + +export kit + +proc schedule*(w: World, s: Side) = + ## Run once a round, before the exec sweep. + s.denCommitted = false + s.hasDenTarget = false + s.denTarget = loc(-1, -1) + if w.brokenChassis: return ## the negative control never commits + if w.currentRound < s.doctrine.denClearRound: return + ## A faction with nothing but its archons does not go den hunting — but the + ## gate is an ABSOLUTE four attackers, not three per archon. Measured on the + ## `small` pool: with the per-archon form, a three-archon faction under + ## pressure never reached nine standing attackers at the same time and + ## therefore NEVER BROKE A DEN in 3 000 rounds, while the horde escalated to + ## x3 — so the knob could not be spent and the game had exactly one + ## outcome. + if s.attackers < 4: return + var bestScore = -1.0e18 + for den in w.liveDens(): + let queue = w.denQueueRemaining(den) + var approach = high(int) + for a in s.archons: + approach = min(approach, a.distanceSquaredTo(den.loc)) + if approach == high(int): approach = 0 + let score = float64(queue * 40) - float64(approach) + if score > bestScore: + bestScore = score + s.denTarget = den.loc + s.hasDenTarget = true + s.denCommitted = s.hasDenTarget + +func denApproachSquare*(w: World, s: Side, r: Robot): Loc = + ## Where a strike-group member wants to stand: inside its own attack radius + ## of the den but OUTSIDE the eight adjacent squares, so it never pays the + ## 10-damage proximity charge while it works. + result = loc(-1, -1) + if not s.hasDenTarget: return + let den = s.denTarget + if r.kind.attackRadiusSquared() <= 2: + ## A GUARD's reach is r2 2, which IS adjacent: it has to stand in the + ## damage ring, and that is the price of a melee unit on a den. + return den + var bestScore = -1.0e18 + for l in w.locationsWithinRadiusSquared(den, r.kind.attackRadiusSquared()): + if not r.spend(1): break + if l.distanceSquaredTo(den) <= 2: continue + if w.isLocationOccupied(l) and not (l == r.loc): continue + if rubbleBlocks(w.getRubble(l), r.kind): continue + let score = -float64(l.distanceSquaredTo(r.loc)) - w.getRubble(l) / 10.0 + if score > bestScore: + bestScore = score + result = l diff --git a/src/battlecode/years/bc16/chassis/econ.nim b/src/battlecode/years/bc16/chassis/econ.nim new file mode 100644 index 0000000..38aee6f --- /dev/null +++ b/src/battlecode/years/bc16/chassis/econ.nim @@ -0,0 +1,116 @@ +## `bulwark`'s economy: `plan()`, `attackMix()`, `queue()` and the per-archon +## commitment ledger — the ONLY place parts are ever committed. +## +## It also holds the UNCONDITIONAL FLOOR of the anti-inert rule, which is +## independent of every knob: at least `AttackersPerArchonFloor = 3` attackers +## per archon, and a build order whenever the stockpile is above 200. Every +## knob moves HOW MUCH OF WHAT, WHEN — never WHETHER IT PLAYS. + +import ../world +import kit + +export kit + +func attackerTarget*(w: World, s: Side): int = + ## `plan()`: the attacker census the faction wants standing, by opening. + ## `turtle` HALVES it, never zeroes it (the anti-inert rule). + let archons = max(1, s.archons.len) + let base = case s.doctrine.opening + of opTurtle: 6 * archons + of opSoldierViperAggro: 12 * archons + of opScoutZombiePull: 8 * archons + max(AttackersPerArchonFloor * archons, base) + +func scoutTarget*(w: World, s: Side): int = + ## `scout_zombie_pull` commissions 2 SCOUTs per archon by round 200; the + ## other two openings keep one scout per faction as an eye, and + ## `rubble_clear` wants one as the cheapest digger (movementDelay 1.4). + let archons = max(1, s.archons.len) + case s.doctrine.opening + of opScoutZombiePull: 2 * archons + else: + if s.doctrine.rubbleClear == rcNever: 1 else: 2 + +func wantsGuardNext*(w: World, s: Side): bool = + ## `attackMix()`: the percentage of the ATTACKER budget that goes to GUARDs + ## rather than SOLDIERs. Deterministic and census-driven rather than random, + ## so a doctrine's mix is reproducible: build a guard when the guard share + ## of the standing attacker mix is below `guard_ratio`. + if w.brokenChassis: return false ## the negative control never guards + let guards = s.counts[rtGuard] + let soldiers = s.counts[rtSoldier] + let total = guards + soldiers + if s.doctrine.guardRatio >= 100: return true + if s.doctrine.guardRatio <= 0: return false + if total == 0: return s.doctrine.guardRatio >= 50 + (guards * 100) < (s.doctrine.guardRatio * total) + +func canAfford*(w: World, s: Side, kind: RobotType): bool = + w.teamParts(s.team) - s.partsCommitted >= float64(kind.partCost()) + +proc commit*(s: Side, kind: RobotType) = + s.partsCommitted += float64(kind.partCost()) + +func buildQueue*(w: World, s: Side): seq[RobotType] = + ## `queue()`: what the stockpile buys first when it cannot buy everything. + ## The list is in priority order and the archon takes the first entry it + ## can afford, so a faction is never idle with parts in the bank. + let d = s.doctrine + var wantTurret = s.counts[rtTurret] + s.counts[rtTtm] < d.turretCount + var wantScout = s.counts[rtScout] < scoutTarget(w, s) + let wantAttacker = s.attackers < attackerTarget(w, s) + let attackerFloor = + s.attackers < AttackersPerArchonFloor * max(1, s.archons.len) + ## THE FLOOR FIRST, at every knob setting: an attacker before anything else + ## while the faction is under three per archon. + ## + ## ONE EXCEPTION, and it is measured rather than assumed: when the + ## stockpile can afford a TURRET **and still buy an attacker afterwards** + ## (130 + 30 = 160 parts), the turret deficit goes first. Without it a + ## faction in a real war never spends `turret_count` at all — measured on + ## `river` with `turret_count: 3`, ZERO turrets in 900 rounds, because + ## attackers die faster than a receding target fills — and a knob that + ## cannot be spent is a knob without teeth. + if attackerFloor: + if wantTurret and + w.teamParts(s.team) - s.partsCommitted >= + float64(rtTurret.partCost() + rtSoldier.partCost()): + result.add(rtTurret) + if wantsGuardNext(w, s): result.add(rtGuard) + result.add(rtSoldier) + result.add(rtGuard) + return + case d.partsPriority + of ppTurrets: + if wantTurret: result.add(rtTurret) + if wantScout: result.add(rtScout) + if wantAttacker: + if wantsGuardNext(w, s): result.add(rtGuard) else: result.add(rtSoldier) + of ppVipers: + if s.counts[rtViper] * 3 <= s.attackers: result.add(rtViper) + if wantScout: result.add(rtScout) + if wantAttacker: + if wantsGuardNext(w, s): result.add(rtGuard) else: result.add(rtSoldier) + if wantTurret: result.add(rtTurret) + of ppUnits: + if wantScout and s.counts[rtScout] == 0: result.add(rtScout) + ## A TURRET DEFICIT OUTRANKS A FURTHER ATTACKER once the unconditional + ## floor is met: 130 parts and 25 frozen archon turns is a real + ## commitment, and a faction that never gets there because its attacker + ## target keeps receding never spends the knob at all (measured on + ## `river`: without this line a `turret_count: 3` doctrine built ZERO + ## turrets in 900 rounds, because attackers die faster than the target + ## fills). + if wantTurret: result.add(rtTurret) + if wantAttacker: + if wantsGuardNext(w, s): result.add(rtGuard) else: result.add(rtSoldier) + if wantScout: result.add(rtScout) + if d.opening == opSoldierViperAggro and s.counts[rtViper] * 6 <= s.counts[rtSoldier]: + result.add(rtViper) + ## A viper per six soldiers once parts allow, for the aggro opening — and a + ## soldier as the always-affordable tail, so a stockpile above 200 always + ## has somewhere to go. + if w.teamParts(s.team) - s.partsCommitted > 200.0: + if wantsGuardNext(w, s): result.add(rtGuard) + result.add(rtSoldier) + result.add(rtSoldier) diff --git a/src/battlecode/years/bc16/chassis/greenhorn.nim b/src/battlecode/years/bc16/chassis/greenhorn.nim new file mode 100644 index 0000000..5652e29 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/greenhorn.nim @@ -0,0 +1,72 @@ +## `greenhorn` — the weak floor and the parity oracle's other side. +## +## **IT MAY NOT GAIN BEHAVIOUR: it is one side of the differential oracle.** +## `tools/oracle/bc16/bc16greenhorn/RobotPlayer.java` is its Java twin, +## statement for statement, and `tests/test_bc16_greenhorn.nim` asserts the +## `Random(2016)` call sequence and the branch order below against a recorded +## oracle trace. There is no upstream `examplefuncsplayer` for 2016 in the +## engine repository (the 2016 scaffold is a separate, unarchived project), so +## this bot is DEFINED HERE and the Java side is written from this +## specification rather than the other way round — which is why the two are +## kept to a shape small enough to verify by reading: +## +## 1. it seeds `new java.util.Random(2016)` PER ROBOT at construction and +## calls only `nextInt(8)` (static fields are per robot under the +## instrumenter, so every unit carries its own stream and it needs no +## determinism patch); +## 2. an **ARCHON**: if `getTeamParts() >= 30` and the core is ready, pick +## `d = DIRECTIONS[rng.nextInt(8)]` and `build(d, SOLDIER)` if +## `canBuild(d, SOLDIER)`; otherwise, if the core is ready, +## `move(DIRECTIONS[rng.nextInt(8)])` if it can; otherwise nothing. IT +## NEVER REPAIRS AND NEVER ACTIVATES; +## 3. a **SOLDIER**: `senseHostileRobots(myLoc, attackRadiusSquared)`; if the +## array is non-empty and the weapon is ready, +## `attackLocation(hostiles[0].location)`; else if the core is ready, +## `move(DIRECTIONS[rng.nextInt(8)])` if it can; +## 4. **every other type does nothing at all** — so `greenhorn` never builds +## a guard, a scout, a viper or a turret, never clears rubble, never sends +## a signal, never activates a neutral and never kills a den. That is what +## being the weak floor means, and it is why the `docker-smoke` substance +## assertions that need those things are asserted ACROSS THE PAIR and not +## per seat; +## 5. `DIRECTIONS` is N, NE, E, SE, S, SW, W, NW in that order, because +## `nextInt(8)` indexes it. +## +## The `DecisionOps` charge is deliberately light and mirrors what the bot +## actually looks at: one credit per draw and one per hostile examined. + +import ../world + +export world + +proc runGreenhorn*(w: World, r: Robot) = + case r.kind + of rtArchon: + if w.teamParts(r.team) >= float64(rtSoldier.partCost()) and + r.d.isCoreReady(): + if not r.spend(1): return + let d = MoveDirs[int(r.greenhornRng.nextInt(8))] + if w.canBuild(r, d, rtSoldier): + w.doBuild(r, d, rtSoldier) + elif r.d.isCoreReady(): + if not r.spend(1): return + let d = MoveDirs[int(r.greenhornRng.nextInt(8))] + if w.canMove(r, d): + w.doMove(r, d) + of rtSoldier: + var first: Robot = nil + for other in w.senseHostileRobots(r, r.kind.attackRadiusSquared()): + if not r.spend(1): break + first = other + break + if first != nil and r.d.isWeaponReady(): + if w.canAttackLocation(r, first.loc): + w.doAttack(r, first.loc) + elif r.d.isCoreReady(): + if not r.spend(1): return + let d = MoveDirs[int(r.greenhornRng.nextInt(8))] + if w.canMove(r, d): + w.doMove(r, d) + else: + ## SCOUT, GUARD, VIPER, TURRET and TTM: nothing. + discard diff --git a/src/battlecode/years/bc16/chassis/infect.nim b/src/battlecode/years/bc16/chassis/infect.nim new file mode 100644 index 0000000..3d58a96 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/infect.nim @@ -0,0 +1,53 @@ +## `bulwark`'s `infect.nim plan()` — this year's largest unexploited play, and +## the knob no 2016 archetype the idea names ever spent deliberately. +## +## An infected robot that dies leaves NO rubble and STANDS BACK UP as a zombie +## of its own `turnsInto` at the current outbreak multiplier, on the horde's +## team, on the square where it fell — and zombies hunt the NEAREST +## player-controlled robot of EITHER faction. So where a doomed unit dies is a +## real decision: +## +## * `ignore` — never read `getInfectedTurns()`; wounded infected units +## die where they stand, next to their own archons, and hand +## the horde a fresh zombie inside the home ring. This is +## what happens by default, and it is exactly what +## `-d:bc16BrokenChassis` forces. +## * `quarantine` — walk AWAY from every friendly archon and hold at +## >= r2 50 until the counter runs out (10 turns for a zombie +## bite, 20 AND 2 damage a turn for a viper bite, which +## usually kills a 60-HP soldier). +## * `suicide_squad` — walk AT the nearest enemy archon and die there, +## turning a doomed 30-part soldier into a STANDARDZOMBIE +## inside the enemy's ring and a doomed archon into a +## BIGZOMBIE that hunts THEM. + +import ../world +import kit + +export kit + +func infectionMoveWanted*(w: World, s: Side, r: Robot): bool = + if w.brokenChassis: return false ## the negative control ignores it + if s.doctrine.infectionPolicy == ipIgnore: return false + r.inf.isInfected() + +proc plan*(w: World, s: Side, r: Robot): bool {.discardable.} = + ## Returns whether this robot's movement is spoken for this turn. Every + ## other module honours that answer, which is what keeps the policy from + ## fighting the navigator. + if not infectionMoveWanted(w, s, r): return false + case s.doctrine.infectionPolicy + of ipIgnore: false + of ipQuarantine: + let home = s.nearestArchon(r.loc) + if home.x < 0: return false + if home.distanceSquaredTo(r.loc) >= 50: + ## Far enough: hold, so the zombie it becomes spawns in empty ground. + return true + w.stepToward(r, home, away = true) + true + of ipSuicideSquad: + let target = s.nearestEnemyArchon(r.loc) + if target.x < 0: return false + w.stepToward(r, target) + true diff --git a/src/battlecode/years/bc16/chassis/kit.nim b/src/battlecode/years/bc16/chassis/kit.nim new file mode 100644 index 0000000..972e333 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/kit.nim @@ -0,0 +1,314 @@ +## `bulwark`'s shared per-side memory, its navigator and the `DecisionOps` +## charging — the file every other chassis module imports. +## +## **PROVENANCE, stated here because it is a licensing fact and not a +## courtesy.** `TheDuck314/battlecode2016` and `bshimanuki/battlecode2016` +## CARRY NO LICENCE. Neither repository was cloned, read, copied, vendored, +## compiled or translated by this run, and neither contributes a single line +## to it. Every line here is Nim written against this coworld's own `World` +## from the ENGINE's own mechanics and from the three archetypes the run's +## idea text itself names — turret turtle, aggressive soldier/viper, scout +## zombie-pull. `NOTICE` and `docs/RULES-BC16.md` state the same thing. +## +## What the side remembers, and why each piece is cheap: +## +## * **the census**, refreshed once a round before the exec sweep, so every +## robot this round reads the same numbers (and two archons cannot both +## think they are the only one); +## * **the den roster**, read from the map. The whole-map zombie schedule is +## PUBLIC in the real game (`getZombieSpawnSchedule()` is free to every +## robot) and this coworld's own observation hands both cogs the den +## locations, so the chassis reads them rather than rediscovering them — +## recorded in `docs/RULES-BC16.md` as a CHASSIS convenience, never a rule: +## the sim's fog is untouched and no RULE reads this roster; +## * **the enemy archon estimate**, seeded from +## `getInitialArchonLocations(enemy)` (public from round 0) and refined by +## sightings; +## * **the parts ledger**, so two archons cannot promise the same 30 parts; +## * **the navigator**: a cost-aware greedy step with a six-square no-repeat +## history, weighted by the REAL cost of the step +## (`movementDelay x (1.4 if diagonal) x (2.0 if destination rubble >= 50)`, +## impassable at 100 unless the mover ignores rubble). It is deliberately +## NOT a full BFS: at 3000 rounds and a budgeted 300 robots on the board a +## per-robot BFS is the difference between a 15-second game and a +## 150-second one, and `tests/test_bc16_perf.nim` is the gate that decides +## that. Every direction evaluated is charged 1 `DecisionOps`. + +import ../world, ../signals, ../knobs + +export world, knobs + +type + Side* = ref object + team*: Team + doctrine*: Doctrine16 + ## --- census, refreshed once a round --- + archons*: seq[Loc] + counts*: array[RobotType, int] + attackers*: int + ready*: bool + ## --- memory --- + enemyArchons*: seq[Loc] + partsCommitted*: float64 + denCommitted*: bool + denTarget*: Loc + hasDenTarget*: bool + turretSites*: seq[Loc] + claimedNeutrals*: seq[Loc] + frontier*: Loc + partsTargets*: seq[Loc] + ## THE REMEMBERED MAP's parts half: the squares that still hold parts, + ## refreshed every `PartsRefreshRounds` rounds ONCE PER SIDE rather than + ## once per archon, because a whole-map scan per archon per round is the + ## difference between 0.5 ms and 5 ms a round. Only an ARCHON collects + ## parts and it takes the WHOLE square, so this list shrinks + ## monotonically as the map is eaten. + partsRefreshedAt*: int + threats*: seq[Loc] + ## THE DEFENSIVE FLOOR of the anti-inert rule, and it is why a faction + ## answers what is coming at it rather than what it planned for: every + ## hostile robot inside r2 64 of one of our own archons, collected once + ## a round. r2 64 is inside the collective sight of an archon (r2 35) + ## plus the guard screen standing in front of it, so it is what the + ## faction really knows and not a fog violation — the sim's fog is + ## untouched and no RULE reads this list. + +const PartsRefreshRounds* = 40 + +proc newSide*(team: Team, doctrine: Doctrine16): Side = + Side(team: team, doctrine: doctrine, frontier: loc(-1, -1), + denTarget: loc(-1, -1), partsRefreshedAt: -1000) + +# --------------------------------------------------------------------------- +# The census +# --------------------------------------------------------------------------- + +proc refreshCensus*(w: World, s: Side) = + ## Once a round, before the exec sweep. + s.archons.setLen(0) + for k in RobotType: s.counts[k] = 0 + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let r = w.robotsById[id] + if r.team != s.team: continue + s.counts[r.kind] += 1 + if r.kind == rtArchon: s.archons.add(r.loc) + s.attackers = s.counts[rtSoldier] + s.counts[rtGuard] + s.counts[rtViper] + + s.counts[rtTurret] + s.counts[rtTtm] + s.partsCommitted = 0.0 + if s.enemyArchons.len == 0: + s.enemyArchons = w.initialArchonLocations(s.team.opponent()) + ## The frontier is the midpoint between our archon centroid and theirs: the + ## direction the war is in, and the square a new build wants to face. + if s.archons.len > 0 and s.enemyArchons.len > 0: + var ax, ay, bx, by = 0 + for l in s.archons: + ax += l.x + ay += l.y + for l in s.enemyArchons: + bx += l.x + by += l.y + ax = ax div s.archons.len + ay = ay div s.archons.len + bx = bx div s.enemyArchons.len + by = by div s.enemyArchons.len + s.frontier = loc((ax + bx) div 2, (ay + by) div 2) + elif s.archons.len > 0: + s.frontier = s.archons[0] + ## The threat list: what is close enough to our archons to matter. + s.threats.setLen(0) + if s.archons.len > 0: + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let r = w.robotsById[id] + if r.team == s.team or r.team == teamNeutral: continue + if r.kind == rtZombieden: continue + for a in s.archons: + if a.distanceSquaredTo(r.loc) <= 64: + s.threats.add(r.loc) + break + ## The parts memory, refreshed on a fixed cadence. + if w.currentRound - s.partsRefreshedAt >= PartsRefreshRounds: + s.partsRefreshedAt = w.currentRound + s.partsTargets.setLen(0) + for i in 0 ..< w.partsAt.len: + if w.partsAt[i] > 0.0: + s.partsTargets.add(w.indexToLoc(i)) + s.ready = true + +proc nearestThreat*(s: Side, from0: Loc): Loc = + result = loc(-1, -1) + var best = high(int) + for l in s.threats: + let d = l.distanceSquaredTo(from0) + if d < best: + best = d + result = l + +proc nearestPartsTarget*(w: World, s: Side, from0: Loc): Loc = + ## The nearest remembered parts square that still holds parts. A square + ## someone already ate is skipped in place, so the list self-cleans. + result = loc(-1, -1) + var best = high(int) + for l in s.partsTargets: + if w.getParts(l) <= 0.0: continue + let d = l.distanceSquaredTo(from0) + if d < best: + best = d + result = l + +func nearestArchon*(s: Side, from0: Loc): Loc = + ## The nearest friendly archon — the only healing in the game. + result = loc(-1, -1) + var best = high(int) + for l in s.archons: + let d = l.distanceSquaredTo(from0) + if d < best: + best = d + result = l + +func nearestEnemyArchon*(s: Side, from0: Loc): Loc = + result = loc(-1, -1) + var best = high(int) + for l in s.enemyArchons: + let d = l.distanceSquaredTo(from0) + if d < best: + best = d + result = l + +func meanArchonDistance*(s: Side): float64 = + ## The statistic `archon_spread` moves, and the one + ## `tests/test_bc16_knobs.nim` reads. + if s.archons.len < 2: return 0.0 + var total = 0.0 + var pairs = 0 + for i in 0 ..< s.archons.len: + for j in i + 1 ..< s.archons.len: + total += float64(s.archons[i].distanceSquaredTo(s.archons[j])) + pairs += 1 + if pairs == 0: 0.0 else: total / float64(pairs) + +# --------------------------------------------------------------------------- +# Dens and neutrals, read from the map +# --------------------------------------------------------------------------- + +iterator liveDens*(w: World): Robot = + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let r = w.robotsById[id] + if r.kind == rtZombieden: yield r + +proc denQueueRemaining*(w: World, den: Robot): int = + ## How many zombies this den still owes over the rest of the game — the + ## number `dens.nim schedule()` ranks by, and it is public information. + if den.denIndex < 0: return 0 + for row in w.map.dens[den.denIndex].schedule: + if row.round >= w.currentRound: + for c in row.counts: result += c + +func nearDenWithQueue*(w: World, l: Loc): bool = + ## True when `l` is one of the eight squares around a den that still has a + ## queue — the squares that take 10 damage a round. The chassis never walks + ## its LAST archon into one of them, at any knob setting. + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let r = w.robotsById[id] + if r.kind != rtZombieden: continue + if r.loc.isAdjacentTo(l): + for i in 0 .. 3: + if r.denQueue[i] > 0: return true + ## A den with an empty queue this instant may still fill it next round, + ## so a scheduled wave inside twenty rounds counts too. + if r.denIndex >= 0: + for row in w.map.dens[r.denIndex].schedule: + if row.round >= w.currentRound and + row.round <= w.currentRound + 20: + return true + false + +# --------------------------------------------------------------------------- +# The navigator +# --------------------------------------------------------------------------- + +func stepCost*(w: World, r: Robot, d: Dir): float64 = + ## The REAL cost of one step: `movementDelay x factor1 x factor3`. This is + ## what makes the navigator prefer a longer flat route to a shorter one + ## through rubble 60, which is the whole point of `rubble_clear`. + let dest = r.loc + d + RobotSpecs[r.kind].movementDelay * moveFactor1(d) * + moveFactor3(w.getRubble(dest), r.kind) + +proc rememberStep(r: Robot, l: Loc) = + r.noRepeat.add(l) + if r.noRepeat.len > 6: + r.noRepeat.delete(0) + +func recentlyVisited(r: Robot, l: Loc): bool = + for v in r.noRepeat: + if v == l: return true + false + +proc stepToward*(w: World, r: Robot, target: Loc, + away = false): bool {.discardable.} = + ## One cost-aware step toward (or away from) `target`, with the no-repeat + ## history breaking oscillation. Charges 1 `DecisionOps` per direction + ## evaluated and returns whether the robot moved. + if not canMoveType(r.kind): return false + if not r.d.isCoreReady(): return false + if target.x < 0: return false + var bestDir = dNone + var bestScore = -1e18 + for d in MoveDirs: + if not r.spend(1): break + if not w.canMove(r, d): continue + let dest = r.loc + d + let before = float64(r.loc.distanceSquaredTo(target)) + let after = float64(dest.distanceSquaredTo(target)) + var gain = if away: after - before else: before - after + ## Normalise the gain by the cost of the step, so a diagonal onto rubble + ## 60 (core 5.6) is worth less than a cardinal on flat ground (core 2). + let cost = w.stepCost(r, d) + var score = gain / max(0.5, cost) + if recentlyVisited(r, dest): score -= 6.0 + if score > bestScore: + bestScore = score + bestDir = d + if bestDir == dNone: + return false + if bestScore <= 0.0: + ## WALL-FOLLOW. A purely greedy step returns false here, and on this + ## year's maps that is the difference between a working faction and a + ## stuck one: `checkers` has 450 of its 900 squares at rubble 200 and + ## `caverns` 1 078 of 1 892, so a unit whose every distance-reducing step + ## is blocked has to be allowed a LATERAL one. The six-square no-repeat + ## history is what bounds the oscillation that permits — it is why the + ## history exists. + var lateral = dNone + var lateralScore = -1.0e18 + for d in MoveDirs: + if not r.spend(1): break + if not w.canMove(r, d): continue + let dest = r.loc + d + if recentlyVisited(r, dest): continue + let score = -float64(dest.distanceSquaredTo(target)) - + w.stepCost(r, d) + if score > lateralScore: + lateralScore = score + lateral = d + if lateral == dNone: return false + rememberStep(r, r.loc) + return w.doMove(r, lateral) + rememberStep(r, r.loc) + w.doMove(r, bestDir) + +proc stepAnywhere*(w: World, r: Robot): bool {.discardable.} = + ## The last resort: any legal step that is not one we just came from. Used + ## when a unit is boxed in, so a faction can never deadlock itself into + ## doing nothing. + if not canMoveType(r.kind) or not r.d.isCoreReady(): return false + for d in MoveDirs: + if not r.spend(1): break + if w.canMove(r, d) and not recentlyVisited(r, r.loc + d): + rememberStep(r, r.loc) + return w.doMove(r, d) + false diff --git a/src/battlecode/years/bc16/chassis/micro.nim b/src/battlecode/years/bc16/chassis/micro.nim new file mode 100644 index 0000000..9979072 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/micro.nim @@ -0,0 +1,67 @@ +## `bulwark`'s movement micro: `kite()` (per `zombie_kiting`) and `retreat()` +## (per `retreat_hp`). +## +## Both have teeth because the 2016 delay table is asymmetric: a +## STANDARDZOMBIE pays movementDelay 3 and a BIGZOMBIE 4 while a SOLDIER pays +## 2 and a SCOUT 1.4, so a soldier can outrun both and shoot from r2 <= 13 +## which is outside their r2 2 reach — but a FASTZOMBIE pays 1.4 AND ignores +## rubble, so it cannot be kited and must be blocked or eaten. +## +## At EVERY setting of either knob the chassis still takes an attack that +## KILLS its target (`combat.nim`) and still fights when cornered, because +## refusing a free kill is not a strategy. + +import ../world +import kit + +export kit + +func healthPct*(r: Robot): int = + if r.maxHealth <= 0.0: 100 else: int((r.health * 100.0) / r.maxHealth) + +func kites*(s: Side, k: RobotType): bool = + ## `never`: nothing kites. `ranged_only`: soldiers, vipers and turrets do — + ## guards do not, because the guard IS the block. `always`: guards too. + case s.doctrine.zombieKiting + of zkNever: false + of zkRangedOnly: k == rtSoldier or k == rtViper + of zkAlways: k == rtSoldier or k == rtViper or k == rtGuard + +proc closingZombie*(w: World, r: Robot): tuple[ok: bool, at: Loc] = + ## The nearest zombie that can already reach us, or is one step from it, + ## and that we are FASTER than. A FASTZOMBIE (1.4) and a BIGZOMBIE (4, but + ## it ignores rubble) are handled by their delays, not by a special case. + result = (ok: false, at: loc(-1, -1)) + var best = high(int) + for other in w.senseHostileRobots(r, 25): + if not r.spend(1): break + if other.team != teamZombie: continue + if RobotSpecs[other.kind].movementDelay <= + RobotSpecs[r.kind].movementDelay: continue + let d = other.loc.distanceSquaredTo(r.loc) + if d <= best: + best = d + result = (ok: true, at: other.loc) + +proc kite*(w: World, s: Side, r: Robot): bool {.discardable.} = + ## Back off a closing zombie instead of trading — but only while we are + ## still able to shoot it from outside its reach. + if not kites(s, r.kind): return false + let threat = w.closingZombie(r) + if not threat.ok: return false + let d = threat.at.distanceSquaredTo(r.loc) + if d > r.kind.attackRadiusSquared(): return false + if d > 5: return false ## already outside its reach + w.stepToward(r, threat.at, away = true) + +proc retreat*(w: World, s: Side, r: Robot): bool {.discardable.} = + ## Disengage toward the nearest friendly archon — the only healing in the + ## game, 1 health a turn, free. At 0 nothing retreats; at 100 a unit + ## withdraws on the first damage it takes. + if s.doctrine.retreatHp <= 0: return false + if r.kind == rtArchon: return false + if healthPct(r) >= s.doctrine.retreatHp: return false + let home = s.nearestArchon(r.loc) + if home.x < 0: return false + if home.distanceSquaredTo(r.loc) <= 4: return false + w.stepToward(r, home) diff --git a/src/battlecode/years/bc16/chassis/neutral.nim b/src/battlecode/years/bc16/chassis/neutral.nim new file mode 100644 index 0000000..66a2d44 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/neutral.nim @@ -0,0 +1,70 @@ +## `bulwark`'s `neutral.nim plan()` — free units for two core delay. +## +## Measured: 80 of the 98 official maps carry neutrals, the played pool +## carries 0 to 26 each, and `caverns` and `industrial` each carry two neutral +## ARCHONS — a whole extra tiebreak rung, another repair field and another +## parts collector, for zero parts. +## +## `never`: archons never detour, and the horde eats the neutrals (rule +## 5.5h). `opportunistic`: activate anything already within r2 <= 8 of the +## archon's intended path. `hunt`: route archons deliberately along the +## roster, nearest-first with neutral ARCHONs and TURRETs first by value. + +import ../world +import kit + +export kit + +func neutralValue*(k: RobotType): int = + ## `partCost` with ARCHON above everything — an archon cannot be built at + ## any price, so its value is not its cost. + case k + of rtArchon: 1000 + of rtTurret: 130 + of rtViper: 120 + of rtSoldier, rtGuard: 30 + of rtScout: 25 + else: 0 + +proc activateAdjacent*(w: World, s: Side, r: Robot): bool {.discardable.} = + ## The activation itself: r2 <= 2, so the eight neighbours and the archon's + ## own square. Taken at EVERY setting except `never`, because a neutral + ## already in reach costs nothing but two core delay. + if r.kind != rtArchon: return false + if s.doctrine.neutralActivation == naNever: return false + var bestAt = loc(-1, -1) + var bestValue = 0 + for d in MoveDirs: + if not r.spend(1): break + let target = r.loc + d + if not w.canActivate(r, target): continue + let other = w.getRobot(target) + if other == nil: continue + let value = neutralValue(other.kind) + if value > bestValue: + bestValue = value + bestAt = target + if bestAt.x < 0: return false + w.doActivate(r, bestAt) + +proc target*(w: World, s: Side, r: Robot): Loc = + ## Where an archon should walk to pick a neutral up, or `(-1, -1)`. + result = loc(-1, -1) + if s.doctrine.neutralActivation == naNever: return + let radius = if s.doctrine.neutralActivation == naHunt: 2000 else: 8 + var bestScore = -1.0e18 + for other in w.senseNearbyRobots(r, -1): + if not r.spend(1): break + if other.team != teamNeutral: continue + let d = other.loc.distanceSquaredTo(r.loc) + if d > radius: continue + var claimed = false + for c in s.claimedNeutrals: + if c == other.loc: claimed = true + if claimed: continue + let score = float64(neutralValue(other.kind) * 10) - float64(d) + if score > bestScore: + bestScore = score + result = other.loc + if result.x >= 0: + s.claimedNeutrals.add(result) diff --git a/src/battlecode/years/bc16/chassis/rubble.nim b/src/battlecode/years/bc16/chassis/rubble.nim new file mode 100644 index 0000000..fc788ae --- /dev/null +++ b/src/battlecode/years/bc16/chassis/rubble.nim @@ -0,0 +1,61 @@ +## `bulwark`'s `rubble.nim plan()` — rubble is this year's terrain and its +## economy is exact. +## +## `r -> max(0, 0.95r - 10)` per action; >= 100 is impassable to everything +## but a SCOUT, a FASTZOMBIE and a BIGZOMBIE; >= 50 DOUBLES every movement and +## cooldown charge; and every uninfected corpse adds its own max health, so a +## battle line bricks itself up. Measured on the played pool: `caverns` starts +## with 1 078 of 1 892 squares impassable, `boxy` has squares at 55 555 and +## `collision` at 9 999 — clearing a 100 to 0 takes FOURTEEN actions, a 1000 +## takes about 55, and a 55 555 is not worth touching, which is what the +## "never above 200" rule below encodes. +## +## A TURRET and a TTM cannot clear at any setting (`canClearRubble()`), and a +## SCOUT is preferred wherever one is available: movementDelay 1.4 is the +## cheapest digger in the game. + +import ../world +import kit + +export kit + +const NeverClearAbove* = 200.0 + ## Above this a clear is 55+ actions for one square and the navigator would + ## rather walk around. `zigzag`'s 10^6 and `boxy`'s 55 555 are the reason + ## this rule exists rather than a "clear the highest" heuristic. + +func clearsAtAll*(s: Side, k: RobotType): bool = + canClearRubble(k) and s.doctrine.rubbleClear != rcNever + +proc plan*(w: World, s: Side, r: Robot): bool {.discardable.} = + ## Clear one square if this robot should. Returns whether the action was + ## taken (it costs the core, so the caller must not also move). + if not clearsAtAll(s, r.kind): return false + if not r.d.isCoreReady(): return false + let home = s.nearestArchon(r.loc) + let aggressive = s.doctrine.rubbleClear == rcAggressive + var bestDir = dNone + var bestScore = 0.0 + for d in MoveDirs: + if not r.spend(1): break + let target = r.loc + d + if not w.onTheMap(target): continue + if w.isLocationOccupied(target): continue + let rubble = w.getRubble(target) + if rubble <= 0.0 or rubble > NeverClearAbove: continue + ## `paths`: only what actually blocks — a square at or above 100 — and + ## only near home or on the way somewhere. `aggressive`: additionally + ## flatten anything at or above 50 inside the archon ring, which is what + ## makes a `turtle` opening fast rather than merely safe. + var wanted = rubble >= RubbleObstructionThresh + if aggressive and rubble >= RubbleSlowThresh and home.x >= 0 and + home.distanceSquaredTo(target) <= 100: + wanted = true + if not wanted: continue + ## Prefer the square that opens with the fewest actions. + let score = 1000.0 - rubble + if score > bestScore: + bestScore = score + bestDir = d + if bestDir == dNone: return false + w.doClearRubble(r, bestDir) diff --git a/src/battlecode/years/bc16/chassis/scenario16.nim b/src/battlecode/years/bc16/chassis/scenario16.nim new file mode 100644 index 0000000..db93723 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/scenario16.nim @@ -0,0 +1,134 @@ +## The Tier A' scenario bot: `scenario16.nim`, the Nim twin of +## `tools/oracle/bc16/bc16scenario/RobotPlayer.java` and its three variants, +## written LINE FOR LINE against them and selected by `-d:bc16Scenario` +## (+ `-d:bc16ScenarioTurn` / `-d:bc16ScenarioAnnihilate` / +## `-d:bc16ScenarioTie`). +## +## Tier A cannot cover any PLAYER action — an idle bot never builds, moves, +## attacks, clears, packs, repairs or activates — so this bot exists to force +## every rare path EARLY and DETERMINISTICALLY. Three properties are +## deliberate and all three are asserted by the oracle job: +## +## 1. **no RNG at all**: every decision is a function of `getRoundNum()` and +## the robot's own type, so the two sides cannot drift for a reason that is +## not a rules difference; +## 2. **cheap**: the Java side asserts at the end of every turn that +## `Clock.getBytecodeNum()` is at or below `bytecodeLimit - 8000` and +## `System.exit(4)` otherwise, so the engine's own `amountToDecrement` is +## exactly 1.0 and V1 is never exercised; +## 3. **scripted by round number**, so the trace lines that prove each path +## fired are at known rounds and `ci.yml` can assert them off the JAVA +## trace rather than trusting the comparison. +## +## The script, by round (an ARCHON does the building; every built unit follows +## its own type's script): +## +## round 1 build a SCOUT to the north (buildTurns 20, proves the freeze) +## round 30 build a SOLDIER to the east (12) +## round 50 build a GUARD to the south (10) +## round 70 build a VIPER to the west (30) +## round 110 build a TURRET to the north-east (25) +## every round after 140, if a friendly non-archon is in r2 <= 24: REPAIR it +## (1 HP, zero delay, once a turn — the one-per-turn cap proved by +## calling it twice) +## round 150+ activate any NEUTRAL in r2 <= 2 (no rubble, no turn, +## immediately active) +## SOLDIER: attack the square 3 east of itself on every even round — an +## EMPTY square if nothing is there, which is legal and costs full +## delay — and step east on every odd round, proving the diagonal +## and rubble factors separately as it crosses the map +## GUARD: step south-east every round (diagonal x rubble factors) +## SCOUT: clear the rubble to its north every round if there is any, else +## step north — the cheapest digger, and the 100 -> 0 in fourteen +## actions +## VIPER: attack the square 4 west of itself every third round (infects +## for 20 turns at 2 damage a turn) +## TURRET: attack the square 3 north of itself every round (r2 9, inside +## [6, 40]); pack at round 400 and unpack at round 430, proving +## 10-on-both twice +## +## `bc16ScenarioTurn` additionally lets an infected SOLDIER, SCOUT and ARCHON +## die; `bc16ScenarioAnnihilate` walks soldiers onto the enemy's single archon +## until `DESTROYED` fires; `bc16ScenarioTie` mirrors both sides so the ladder +## walks PWNED -> OWNED -> BARELY_BEAT and, on one seed, +## WON_BY_DUBIOUS_REASONS. + +import ../world + +export world + +proc runScenario16*(w: World, r: Robot) = + let round = w.currentRound + case r.kind + of rtArchon: + if round == 1 and w.canBuild(r, dNorth, rtScout): + w.doBuild(r, dNorth, rtScout) + return + if round == 30 and w.canBuild(r, dEast, rtSoldier): + w.doBuild(r, dEast, rtSoldier) + return + if round == 50 and w.canBuild(r, dSouth, rtGuard): + w.doBuild(r, dSouth, rtGuard) + return + if round == 70 and w.canBuild(r, dWest, rtViper): + w.doBuild(r, dWest, rtViper) + return + if round == 110 and w.canBuild(r, dNortheast, rtTurret): + w.doBuild(r, dNortheast, rtTurret) + return + if round >= 150: + for d in MoveDirs: + if w.canActivate(r, r.loc + d): + w.doActivate(r, r.loc + d) + return + if round >= 140: + for other in w.senseNearbyRobots(r, 24): + if other.team == r.team and other.kind != rtArchon and + w.canRepair(r, other.loc): + w.doRepair(r, other.loc) + ## The one-per-turn cap: the second call must be refused, and the + ## Java side calls it twice for exactly that reason. + discard w.canRepair(r, other.loc) + return + of rtSoldier: + if (round mod 2) == 0: + let at = r.loc.translate(3, 0) + if w.canAttackLocation(r, at) and r.d.isWeaponReady(): + w.doAttack(r, at) + return + else: + if w.canMove(r, dEast) and r.d.isCoreReady(): + w.doMove(r, dEast) + return + of rtGuard: + if w.canMove(r, dSoutheast) and r.d.isCoreReady(): + w.doMove(r, dSoutheast) + return + of rtScout: + if r.d.isCoreReady(): + if w.getRubble(r.loc + dNorth) > 0.0 and w.canClearRubble(r, dNorth): + w.doClearRubble(r, dNorth) + return + if w.canMove(r, dNorth): + w.doMove(r, dNorth) + return + of rtViper: + if (round mod 3) == 0: + let at = r.loc.translate(-4, 0) + if w.canAttackLocation(r, at) and r.d.isWeaponReady(): + w.doAttack(r, at) + return + of rtTurret: + if round == 400: + w.doTransform(r) + return + let at = r.loc.translate(0, -3) + if w.canAttackLocation(r, at) and r.d.isWeaponReady(): + w.doAttack(r, at) + return + of rtTtm: + if round >= 430: + w.doTransform(r) + return + else: + discard diff --git a/src/battlecode/years/bc16/chassis/turret.nim b/src/battlecode/years/bc16/chassis/turret.nim new file mode 100644 index 0000000..29718d8 --- /dev/null +++ b/src/battlecode/years/bc16/chassis/turret.nim @@ -0,0 +1,72 @@ +## `bulwark`'s `turret.nim` — `target()`, `site()` and the pack/unpack +## schedule. +## +## A TURRET is 130 parts and 25 build turns of a FROZEN archon, cannot shoot +## anything closer than r2 6, reaches r2 40 for 13 damage (the longest reach +## in the game), and must PACK into a TTM — 10 delay on BOTH counters — to +## move at all, then UNPACK (another 10 on both) to shoot again. A relocation +## is therefore twenty turns of silence, and this module takes one only when +## the new site is clearly better. + +import ../world +import kit, combat + +export kit + +const RelocateGain* = 2 + ## The covered-lane improvement a new site must show before a turret pays + ## twenty turns of silence for it. + +func coveredLanes*(w: World, s: Side, at: Loc): int = + ## How many den approach lanes a site covers: dens whose straight line to + ## our archon centroid passes inside r2 40 of `at`, which is what a turret + ## can actually shoot. + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let den = w.robotsById[id] + if den.kind != rtZombieden: continue + if den.loc.distanceSquaredTo(at) <= 400: result += 1 + if s.frontier.x >= 0 and s.frontier.distanceSquaredTo(at) <= 400: + result += 1 + +proc site*(w: World, s: Side, r: Robot): Loc = + ## The lowest-rubble square inside the archon ring that covers the most + ## lanes without sitting inside r2 6 of an archon (a turret cannot shoot + ## what is standing on top of it). + result = loc(-1, -1) + let home = s.nearestArchon(r.loc) + if home.x < 0: return + var bestScore = -1.0e18 + for l in w.locationsWithinRadiusSquared(home, 40): + if not r.spend(1): break + if w.isLocationOccupied(l) and not (l == r.loc): continue + if w.getRubble(l) >= RubbleObstructionThresh: continue + if l.distanceSquaredTo(home) < TurretMinimumRange: continue + let score = float64(coveredLanes(w, s, l) * 100) - w.getRubble(l) + if score > bestScore: + bestScore = score + result = l + +proc runTurret*(w: World, s: Side, r: Robot) = + ## Unpack-and-hold: shoot the best target in [6, 40], and pack only when + ## `site()` improves the covered-lane count by at least `RelocateGain`. + let pick = pickTarget(w, s, r, s.denCommitted) + if pick.ok: + w.doAttack(r, pick.at) + return + let here = coveredLanes(w, s, r.loc) + let want = site(w, s, r) + if want.x >= 0 and not (want == r.loc) and + coveredLanes(w, s, want) >= here + RelocateGain: + r.hasTask = true + r.taskLoc = want + w.doTransform(r) ## pack: 10 on both counters + +proc runTtm*(w: World, s: Side, r: Robot) = + ## A TTM cannot attack and cannot clear rubble: it walks to its site and + ## unpacks. If it has no site it unpacks where it stands rather than + ## wandering, because a TTM is a 100-HP unit with no weapon. + if r.hasTask and r.taskLoc.x >= 0 and not (r.loc == r.taskLoc): + if w.stepToward(r, r.taskLoc): return + r.hasTask = false + w.doTransform(r) ## unpack diff --git a/src/battlecode/years/bc16/constants.nim b/src/battlecode/years/bc16/constants.nim new file mode 100644 index 0000000..219d948 --- /dev/null +++ b/src/battlecode/years/bc16/constants.nim @@ -0,0 +1,262 @@ +## Battlecode 2016 "Zombie Invasion" gameplay constants -- GENERATED, do not edit. +## +## Source: github.com/battlecode/battlecode-server-2016 at commit `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`, +## files `common/GameConstants.java` and `common/RobotType.java`, +## read by `tools/gen_year_constants.py --year bc16`. 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 OFFICIAL 2016 SPEC IS LOST (dead S3, dead battlecode.org, no +## Wayback copy) and there is NO `SPEC_VERSION` field in this year's +## `GameConstants` -- so the engine source IS the spec, this table is +## its transcription, and the oracle jar is pinned by sha256 AND size +## in `tools/oracle/bc16/jar.lock` instead of by a version string. +## +## 2016 IS A FLOAT64 YEAR: health, damage, both delay counters, +## rubble, parts and every multiplier are Java `double`, and there is +## NO float32 anywhere in the rule set. IEEE-754 binary64 add, +## subtract, multiply, divide and compare are exactly specified and +## identical on x86-64 SSE2 and on wasm32, so reproducing each +## expression in the engine's own order is bit-exact by construction. +## The two non-algebraic functions on gameplay paths -- +## `Math.pow(x, 1.5)` in `decrementDelays` and `(int) Math.sqrt(r2)` +## in the radius scans -- both have finite domains and are TABLED in +## `data/bc16/tables.json`, so the runtime path has no +## transcendental at all. + +const EngineCommit* = "11a0b09f26a70da19f33a61ebec4ceaf6e161aa3" +const OracleJarVersion* = "2016.0.2.2" + +type + RobotType* = enum + ## `common/RobotType.java` in `values()` order. THE ORDINAL IS + ## LOAD-BEARING: `ZombieCount.compareTo` sorts by it and the den's + ## spawn priority reads it (the no-`break` loop takes the LAST + ## non-zero type, so the priority is BIGZOMBIE, FASTZOMBIE, + ## RANGEDZOMBIE, STANDARDZOMBIE). + rtZombieden = "ZOMBIEDEN" + rtStandardzombie = "STANDARDZOMBIE" + rtRangedzombie = "RANGEDZOMBIE" + rtFastzombie = "FASTZOMBIE" + rtBigzombie = "BIGZOMBIE" + rtArchon = "ARCHON" + rtScout = "SCOUT" + rtSoldier = "SOLDIER" + rtGuard = "GUARD" + rtViper = "VIPER" + rtTurret = "TURRET" + rtTtm = "TTM" + + RobotSpec* = object + ## `common/RobotType.java`'s seventeen constructor arguments, in + ## the file's own order. `spawnSource` and `turnsInto` are the + ## ORDINAL of the named type, or -1 for the engine's `null`. + isBuilding*, isZombie*: bool + infectTurns*, spawnSource*: int + partCost*, buildTurns*: int + maxHealth*, attackPower*: float64 + attackRadiusSquared*: int + movementDelay*, attackDelay*, cooldownDelay*: float64 + sensorRadiusSquared*, bytecodeLimit*, strengthWeight*: int + turnsInto*: int + ignoresRubble*: bool + +const + MapMinHeight*: int = 30 + MapMaxHeight*: int = 80 + MapMinWidth*: int = 30 + MapMaxWidth*: int = 80 + TeamMemoryLength*: int = 32 + NumberOfIndicatorStrings*: int = 3 + ExceptionBytecodePenalty*: int = 500 + NumberOfArchonsMax*: int = 4 + BroadcastRangeMultiplier*: float64 = 2.0 + BroadcastBaseDelayIncrease*: float64 = 0.05 + BroadcastAdditionalDelayIncrease*: float64 = 0.03 + PartsInitialAmount*: float64 = 300.0 + ArchonPartIncome*: float64 = 2.0 + PartIncomeUnitPenalty*: float64 = 0.01 + DenPartReward*: float64 = 200.0 + RubbleObstructionThresh*: float64 = 100.0 + RubbleSlowThresh*: float64 = 50.0 + RubbleClearPercentage*: float64 = 0.05 + RubbleClearFlatAmount*: float64 = 10.0 + RubbleFromTurretFactor*: float64 = 0.3333333333333333 + GuardZombieMultiplier*: float64 = 2.0 + GuardDefenseThreshold*: float64 = 10.0 + GuardDamageReduction*: float64 = 4.0 + ViperInfectionDamage*: float64 = 2.0 + TurretMinimumRange*: int = 6 + TurretTransformDelay*: int = 10 + DiagonalDelayMultiplier*: float64 = 1.4 + ArchonRepairAmount*: float64 = 1.0 + ArchonActivationRange*: int = 2 + DenSpawnProximityDamage*: float64 = 10.0 + OutbreakTimer*: int = 300 + ArmageddonDayTimer*: int = 300 + ArmageddonNightTimer*: int = 900 + ArmageddonDayOutbreakMultiplier*: float64 = 1.0 + ArmageddonNightOutbreakMultiplier*: float64 = 2.0 + ArmageddonDayZombieRegeneration*: float64 = -0.2 + ArmageddonNightZombieRegeneration*: float64 = 0.05 + SignalQueueMaxSize*: int = 1000 + BasicSignalsPerTurn*: int = 5 + MessageSignalsPerTurn*: int = 20 + GameDefaultSeed*: int = 6370 + GameDefaultRounds*: int = 3000 + + DecisionOpsWide*: int = 2000 + DecisionOpsStandard*: int = 1000 + ## Replace `RobotType.bytecodeLimit` outside the JVM: 2000 for an + ## ARCHON and a SCOUT, 1000 for everything else, 0 for a robot + ## with `!isActive()`. No mid-turn resumption, no mid-primitive + ## cut, enforced by the sim rather than by the bot. + + RobotSpecs*: array[RobotType, RobotSpec] = [ + rtZombieden: RobotSpec(isBuilding: true, isZombie: true, + infectTurns: 0, spawnSource: -1, + partCost: 0, buildTurns: 0, + maxHealth: 2000, attackPower: 0, + attackRadiusSquared: 0, + movementDelay: 0, attackDelay: 0, + cooldownDelay: 0, + sensorRadiusSquared: -1, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: -1, + ignoresRubble: false), + rtStandardzombie: RobotSpec(isBuilding: false, isZombie: true, + infectTurns: 10, spawnSource: 0, + partCost: 0, buildTurns: 0, + maxHealth: 60, attackPower: 2.5, + attackRadiusSquared: 2, + movementDelay: 3, attackDelay: 2, + cooldownDelay: 1, + sensorRadiusSquared: -1, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: -1, + ignoresRubble: false), + rtRangedzombie: RobotSpec(isBuilding: false, isZombie: true, + infectTurns: 10, spawnSource: 0, + partCost: 0, buildTurns: 0, + maxHealth: 60, attackPower: 3, + attackRadiusSquared: 13, + movementDelay: 3, attackDelay: 1, + cooldownDelay: 1, + sensorRadiusSquared: -1, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: -1, + ignoresRubble: false), + rtFastzombie: RobotSpec(isBuilding: false, isZombie: true, + infectTurns: 10, spawnSource: 0, + partCost: 0, buildTurns: 0, + maxHealth: 80, attackPower: 3, + attackRadiusSquared: 2, + movementDelay: 1.4, attackDelay: 1, + cooldownDelay: 1, + sensorRadiusSquared: -1, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: -1, + ignoresRubble: true), + rtBigzombie: RobotSpec(isBuilding: false, isZombie: true, + infectTurns: 10, spawnSource: 0, + partCost: 0, buildTurns: 0, + maxHealth: 500, attackPower: 25, + attackRadiusSquared: 2, + movementDelay: 4, attackDelay: 3, + cooldownDelay: 2, + sensorRadiusSquared: -1, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: -1, + ignoresRubble: true), + rtArchon: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 0, spawnSource: -1, + partCost: 0, buildTurns: 0, + maxHealth: 1000, attackPower: 0, + attackRadiusSquared: 24, + movementDelay: 2, attackDelay: 1, + cooldownDelay: 1, + sensorRadiusSquared: 35, bytecodeLimit: 20000, + strengthWeight: 0, turnsInto: 4, + ignoresRubble: false), + rtScout: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 0, spawnSource: 5, + partCost: 25, buildTurns: 20, + maxHealth: 80, attackPower: 0, + attackRadiusSquared: 0, + movementDelay: 1.4, attackDelay: 0, + cooldownDelay: 1, + sensorRadiusSquared: 53, bytecodeLimit: 20000, + strengthWeight: 0, turnsInto: 3, + ignoresRubble: true), + rtSoldier: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 0, spawnSource: 5, + partCost: 30, buildTurns: 12, + maxHealth: 60, attackPower: 4, + attackRadiusSquared: 13, + movementDelay: 2, attackDelay: 2, + cooldownDelay: 1, + sensorRadiusSquared: 24, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: 1, + ignoresRubble: false), + rtGuard: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 0, spawnSource: 5, + partCost: 30, buildTurns: 10, + maxHealth: 145, attackPower: 1.5, + attackRadiusSquared: 2, + movementDelay: 2, attackDelay: 1, + cooldownDelay: 1, + sensorRadiusSquared: 24, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: 1, + ignoresRubble: false), + rtViper: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 20, spawnSource: 5, + partCost: 120, buildTurns: 30, + maxHealth: 120, attackPower: 2, + attackRadiusSquared: 20, + movementDelay: 2, attackDelay: 3, + cooldownDelay: 1, + sensorRadiusSquared: 24, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: 2, + ignoresRubble: false), + rtTurret: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 0, spawnSource: 5, + partCost: 130, buildTurns: 25, + maxHealth: 100, attackPower: 13, + attackRadiusSquared: 40, + movementDelay: 0, attackDelay: 3, + cooldownDelay: 3, + sensorRadiusSquared: 24, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: 2, + ignoresRubble: false), + rtTtm: RobotSpec(isBuilding: false, isZombie: false, + infectTurns: 0, spawnSource: 10, + partCost: 130, buildTurns: 10, + maxHealth: 100, attackPower: 0, + attackRadiusSquared: 0, + movementDelay: 2, attackDelay: 0, + cooldownDelay: 2, + sensorRadiusSquared: 24, bytecodeLimit: 10000, + strengthWeight: 0, turnsInto: 2, + ignoresRubble: false), + ] + + OutbreakMultipliers*: array[13, float64] = [ + ## `RobotType.getOutbreakMultiplier(round)`'s own switch for + ## levels 0..9, then its `default: 3.00 + (level - 9)` arm for + ## 10..12. `level = round / OUTBREAK_TIMER` (integer), applied to + ## a ZOMBIE's maxHealth and attackPower AT THE MOMENT IT SPAWNS + ## and never afterwards; a player unit never scales. A + ## 3000-round game's last round is 2999, so level 9 is the last + ## one a spawn actually reaches -- 10..12 are tabled anyway. + 1.0, + 1.1, + 1.2, + 1.3, + 1.5, + 1.7, + 2.0, + 2.3, + 2.6, + 3.0, + 4.0, + 5.0, + 6.0, + ] + diff --git a/src/battlecode/years/bc16/delays.nim b/src/battlecode/years/bc16/delays.nim new file mode 100644 index 0000000..7479649 --- /dev/null +++ b/src/battlecode/years/bc16/delays.nim @@ -0,0 +1,103 @@ +## The bc16 delay pair: `coreDelay` / `weaponDelay`, their four mutators, the +## pinned `decrementDelays` (V1) and the TWO COMPOSITE HELPERS whose pairing is +## the single easiest way to break this year. +## +## Ported from `world/InternalRobot.java:297-337` and `:378-392` at commit +## `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`. Pure: a `Delays` value knows +## nothing about a `World`, which is why this is its own file. +## +## **The pairing, stated because getting it backwards is invisible until a +## parity trace diverges 400 rounds later:** +## +## activateCoreAction(attackDelay, movementDelay): +## setWeaponDelayUpTo(attackDelay); addCoreDelay(movementDelay) +## activateAttack(attackDelay, movementDelay): +## addWeaponDelay(attackDelay); setCoreDelayUpTo(movementDelay) +## +## They are OPPOSITE in both the set/add choice AND in which counter gets +## which argument. `clearRubble`, `move`, `build` and `activate` go through +## the first; `attackLocation` goes through the second; `repair` goes through +## NEITHER and costs nothing at all. +## +## **Readiness is strictly `< 1` on a float64** (`RobotControllerImpl.java:417, +## 422`), so a unit at exactly 1.0 core delay is NOT ready. +## +## **V1 — `decrementDelays` is pinned to `amountToDecrement = 1.0`.** The +## engine computes +## `1.0 - 0.3 * pow(max(0, 8000 - currentBytecodeLimit + prevBytecodesUsed)/8000, 1.5)`, +## which is a function of LAST TURN'S BYTECODE COUNT. There is no JVM here and +## no bytecode counter, and deriving the term from the chassis's own +## `DecisionOps` would make the chassis's implementation a RULES INPUT — every +## refactor of `bulwark` would change what a round resolves to and would have +## to bump `GameVersion`. `1.0` is exactly what the engine produces for any +## robot inside `limit - 8000` bytecodes (2000 for a 10 000-limit unit, 12 000 +## for an archon or a scout), so the divergence is "every unit behaves like a +## frugal 2016 bot". The engine's whole formula is nevertheless tabled over its +## complete finite domain in `data/bc16/tables.json` and asserted by +## `tests/table_bc16_delay.nim`, and the oracle's own bots assert they stay +## inside the 1.0 branch, so the comparison is defined for the whole game. + +import std/math +import constants + +export constants + +type + Delays* = object + core*: float64 + weapon*: float64 + +const + PinnedDecrement* = 1.0 + ## V1. The value `decrementDelays` produces whenever + ## `prevBytecodesUsed <= bytecodeLimit - 8000`. + +func initDelays*(): Delays = Delays(core: 0.0, weapon: 0.0) + +func isCoreReady*(d: Delays): bool = d.core < 1.0 +func isWeaponReady*(d: Delays): bool = d.weapon < 1.0 + +proc addCoreDelay*(d: var Delays, time: float64) = d.core += time +proc addWeaponDelay*(d: var Delays, time: float64) = d.weapon += time + +proc setCoreDelayUpTo*(d: var Delays, delay: float64) = + d.core = max(d.core, delay) + +proc setWeaponDelayUpTo*(d: var Delays, delay: float64) = + d.weapon = max(d.weapon, delay) + +proc decrementDelays*(d: var Delays) = + ## `InternalRobot.decrementDelays` with `amountToDecrement` pinned to 1.0 + ## (V1). BOTH counters are decremented and EACH is floored at 0.0 + ## separately, exactly as the engine's two independent `if`s do. + d.weapon -= PinnedDecrement + d.core -= PinnedDecrement + if d.weapon < 0.0: d.weapon = 0.0 + if d.core < 0.0: d.core = 0.0 + +proc activateCoreAction*(d: var Delays, attackDelay, movementDelay: float64) = + ## `clearRubble`, `move`, `build`, `activate`. + d.setWeaponDelayUpTo(attackDelay) + d.addCoreDelay(movementDelay) + +proc activateAttack*(d: var Delays, attackDelay, movementDelay: float64) = + ## `attackLocation`, and NOTHING else. + d.addWeaponDelay(attackDelay) + d.setCoreDelayUpTo(movementDelay) + +proc transformDelay*(d: var Delays) = + ## `InternalRobot.transform`: `TURRET_TRANSFORM_DELAY = 10.0` ADDED TO BOTH + ## counters, and there is NO READINESS CHECK on `pack`/`unpack` at all. + d.core += float64(TurretTransformDelay) + d.weapon += float64(TurretTransformDelay) + +func engineDecrement*(bytecodeLimit, prevBytecodesUsed: int): float64 = + ## The engine's WHOLE formula, for the table and the tests only. NO RULE IN + ## THIS PORT CALLS IT (V1): it exists so the divergence is measured rather + ## than asserted. `pow(x, 1.5)` is evaluated here in Nim only inside + ## `tests/table_bc16_delay.nim`, against the JDK-generated table. + let raw = max(0.0, float64(8000 - bytecodeLimit + prevBytecodesUsed)) / 8000.0 + 1.0 - (0.3 * pow(raw, 1.5)) + +when isMainModule: + discard diff --git a/src/battlecode/years/bc16/economy.nim b/src/battlecode/years/bc16/economy.nim new file mode 100644 index 0000000..9994d1e --- /dev/null +++ b/src/battlecode/years/bc16/economy.nim @@ -0,0 +1,69 @@ +## The bc16 parts economy: the income formula, the map's parts, the den bounty +## and the net-worth sums the end ladder and the score read. +## +## Ported from `world/GameWorld.java:533-547` (`takeParts`, +## `adjustResources`), `:622-627` (the income) and `:745-796` (the den bounty, +## which is paid inside `visitAttackSignal` and therefore lives in +## `world.nim`) at commit `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`. +## +## **THE WHOLE ECONOMY IS THREE THINGS AND THIS FILE IS ALL OF THEM:** +## +## 1. `PARTS_INITIAL_AMOUNT = 300.0` per team, credited ONCE by the world's +## constructor (`world.nim`); +## 2. `max(0.0, 2.0 - 0.01 * getRobotCount(team))` per team per round, added +## A FIRST AND THEN B at the end of every round — so income falls linearly +## with army size and REACHES EXACTLY ZERO AT 200 ROBOTS. That is why a +## 2016 army has a natural ceiling, and it is why the survival gate keys on +## archons rather than on starvation; +## 3. the map's parts squares, which are ALL-OR-NOTHING and ARCHON-ONLY +## (`takeParts`, in `world.nim`, called from exactly two places), plus +## `DEN_PART_REWARD = 200.0` for whoever lands the killing blow on a den. +## +## Parts are never created after round 0 except by den bounties and never +## regenerate. There is no upkeep and no supply: the `bytecodeLimit` comment +## about "halved if the robot does not have sufficient supply upkeep" is a +## leftover from 2015 and no supply code exists in this engine. + +import std/os +import world + +export world + +proc dataRoot*(): string = + ## `/data` is where emscripten mounts the preloaded directory in the wasm + ## bundle; `data` is the repo layout the container and the tests use. + ## + ## `getAppDir()` is DELIBERATELY not a candidate: under emscripten it walks + ## `os.getApplAux`, whose `readlink("/proc/self/exe")` returns -1 and whose + ## next line is a `Natural` conversion that raises before anything is opened. + for candidate in ["data", "/data", "/workspace/battlecode/data"]: + if dirExists(candidate / "maps" / "bc16"): + return candidate + "data" + +func incomeFor*(w: World, t: Team): float64 = + ## `Math.max(0.0, ARCHON_PART_INCOME - PART_INCOME_UNIT_PENALTY * + ## getRobotCount(team))`, written in the engine's own order. Named float64 + ## vector: at 137 robots this is exactly `0.63`. + max(0.0, ArchonPartIncome - + PartIncomeUnitPenalty * float64(w.robotCountOf(t))) + +proc addPartsIncome*(w: World) = + ## Rule 4.2, in the engine's order: TEAM A'S STOCKPILE FIRST, then team B's. + ## The order is unobservable today (the two are independent) and is kept + ## anyway, because a future rule that reads one while writing the other + ## would make it observable. + for t in [teamA, teamB]: + let gain = w.incomeFor(t) + w.resources[ord(t)] += gain + w.stats.partsIncomeTenths[ord(t)] += int(gain * 10.0) + +func partsSquares*(w: World): int = + for v in w.partsAt: + if v > 0.0: result += 1 + +func rubbleMeanTenths*(w: World): int = + if w.map.rubble.len == 0: return 0 + var total = 0.0 + for v in w.map.rubble: total += v + int((total / float64(w.map.rubble.len)) * 10.0) diff --git a/src/battlecode/years/bc16/health.nim b/src/battlecode/years/bc16/health.nim new file mode 100644 index 0000000..563d94a --- /dev/null +++ b/src/battlecode/years/bc16/health.nim @@ -0,0 +1,117 @@ +## bc16 health, infection and what a death leaves behind — the PURE half. +## +## Ported from `world/InternalRobot.java:228-293` (the infection counters, +## `processBeingInfected` and `changeHealthLevel`'s cap) and +## `world/GameWorld.java:857-903` (`visitDeathSignal`'s ordered consequences) +## at commit `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`. +## +## **A LAYOUT NOTE, and it is a divergence from the design note's file table +## (recorded in `docs/RULES-BC16.md` §Divergences item 14).** The note asks for +## a `health.nim` carrying "the single `changeHealthLevel` mutation point with +## its cap, its `source == TURRET` flag, its death path, the rubble deposit, +## the infection->zombie conversion and the mid-turn `DESTROYED` check". The +## MUTATION half of that list has to reach world state — the rubble array, the +## occupancy index, the exec list, `spawnRobot` for the zombie that stands up +## — so in Nim it must live beside that state, in `world.nim`, or the two files +## import each other. So the split is: +## +## * **here**: the two infection counters and their independence, the viper +## tick's damage, the health cap, and `deathConsequence` — the ORDERED +## DECISION a death makes (does it leave rubble, how much, does it stand back +## up, and as what); +## * **`world.nim`**: `changeHealthLevel`, which applies exactly that decision +## once, and is still the ONE mutation point for health. +## +## Nothing else moved: `NOTICE`, `knobs.nim`'s pointer and +## `docs/RULES-BC16.md` all name this pair. +## +## **The two infection counters are INDEPENDENT and `isInfected()` is their +## OR** (`:236-246`). A VIPER hit sets `viperInfectedTurns = 20`; ANY zombie +## hit sets `zombieInfectedTurns = 10`; a viper hit does NOT touch the zombie +## counter and vice versa. `processBeingInfected` (`:248-256`) runs inside +## `processEndOfTurn` AND ONLY WHEN `health > 0` (`GameWorld.java:173-175`): +## the viper strain deals EXACTLY 2.0 damage — which can kill, and a robot +## that dies that way IS infected, so it becomes a zombie — and each counter +## that is above zero is then decremented. + +import units + +export units + +type + Infection* = object + zombieTurns*: int + viperTurns*: int + + DeathConsequence* = object + ## `visitDeathSignal`'s two mutually exclusive outcomes, decided once. + ## THE TWO ARE EXCLUSIVE BY CONSTRUCTION: an infected corpse leaves no + ## wall, and an uninfected one never stands up. That single `if` + ## (`GameWorld.java:869`) is why `infection_policy` is a real knob. + rubbleAdded*: float64 + becomesZombie*: bool + zombieType*: RobotType + +func isInfected*(inf: Infection): bool = + inf.zombieTurns > 0 or inf.viperTurns > 0 + +func infectedTurns*(inf: Infection): int = + ## `RobotControllerImpl.getInfectedTurns` == `max` of the two. + max(inf.zombieTurns, inf.viperTurns) + +proc setInfected*(inf: var Infection, attacker: RobotType) = + ## `InternalRobot.setInfected`: a VIPER sets the viper counter to ITS OWN + ## `infectTurns` (20); any zombie sets the zombie counter to its own (10). + ## Anything else does nothing — and the caller only reaches here when + ## `attacker.canInfect() and target.isInfectable()`. + if attacker == rtViper: + inf.viperTurns = RobotSpecs[rtViper].infectTurns + elif RobotSpecs[attacker].isZombie: + inf.zombieTurns = RobotSpecs[attacker].infectTurns + +proc tickInfection*(inf: var Infection): float64 = + ## `processBeingInfected`, split so the caller can apply the damage through + ## the one mutation point. Returns the damage to take (2.0 or 0.0) and + ## decrements each live counter. + ## + ## THE ENGINE'S ORDER IS LOAD-BEARING: it takes the damage FIRST and then + ## decrements the viper counter, so a 20-turn viper infection deals 40 total + ## — not enough on its own to kill a full 60-HP soldier. + result = 0.0 + if inf.viperTurns > 0: + result = ViperInfectionDamage + inf.viperTurns -= 1 + if inf.zombieTurns > 0: + inf.zombieTurns -= 1 + +func cappedHealth*(health, maxHealth: float64): float64 = + ## `changeHealthLevel`'s cap: `if (healthLevel > maxHealth) healthLevel = + ## maxHealth`. Strictly greater, so the cap is idempotent. + if health > maxHealth: maxHealth else: health + +func isDead*(health: float64): bool = health <= 0.0 + ## `changeHealthLevel`'s death test is `<= 0`, so a robot at + ## 0.0000000001 health is alive. + +func deathConsequence*(kind: RobotType, maxHealth: float64, + cause: DeathCause, infected: bool): DeathConsequence = + ## `visitDeathSignal` (`:857-903`) in exactly the engine's order, minus the + ## state mutation: + ## + ## (b) if the cause is NOT `ACTIVATION` and the robot is NOT infected -> + ## `rubble += rubbleFactor * maxHealth`, where `rubbleFactor` is 1.0 + ## normally and 1/3 when a TURRET landed the killing blow; + ## (d) if the robot WAS infected and the cause is not `ACTIVATION` -> + ## spawn `type.turnsInto` on `Team.ZOMBIE` at the same square. + ## + ## `maxHealth` is the DYING ROBOT'S OWN max (outbreak-scaled for a zombie), + ## so a level-9 BIGZOMBIE leaves 1500 rubble and an archon 1000. + result = DeathConsequence(rubbleAdded: 0.0, becomesZombie: false, + zombieType: kind) + if cause == dcActivation: + return + if not infected: + result.rubbleAdded = rubbleFactorFor(cause) * maxHealth + elif hasTurnsInto(kind): + result.becomesZombie = true + result.zombieType = kind.turnsInto() diff --git a/src/battlecode/years/bc16/knobs.nim b/src/battlecode/years/bc16/knobs.nim new file mode 100644 index 0000000..79c463b --- /dev/null +++ b/src/battlecode/years/bc16/knobs.nim @@ -0,0 +1,375 @@ +## The Battlecode 2016 "Zombie Invasion" knob table: ELEVEN knobs, and NO +## `chassis` key. +## +## D1 (the standing review finding): the chassis is not an LLM-selectable +## knob. The chassis a seat drives comes from `PLAYER_SCRIPTED` (scripted +## seats) or is the fixed champion chassis (LLM seats). A submitted `chassis` +## is therefore recorded as an UNKNOWN FIELD and never honoured, and +## `tests/test_bc16_sheet.nim` asserts exactly that — the test fails if anyone +## re-adds the knob. +## +## Unknown key, wrong type or out-of-range value takes THAT FIELD'S DEFAULT +## and the repair is recorded — except the FOUR INTEGER knobs, which CLAMP to +## their range rather than defaulting, so "as many as possible" still means +## something. A sheet can never be rejected, so a cog can never forfeit a +## match by answering badly — only by answering weakly. +## +## **THE ENVELOPE PIN, ITEM 2 (LEARNINGS 2026-09-08).** `applyKnobs16` adds an +## **ABSENT** known key to `defaultsApplied` as well as a repaired one, so +## `sheet_defaults_applied` for a bc16 seat is `[]` only when the cog really +## set all eleven knobs. This is deliberately **not** done year-neutrally in +## `sheet.nim`: doing so would change what a bc20/bc21/bc23/bc24/bc25/bc26 +## episode records in that array, which is the one thing "prior years' +## semantics unchanged" forbids. `tests/test_bc16_sheet.nim` asserts a bc16 +## empty sheet reports all eleven names AND that a bc23 empty sheet still +## reports none, so the change is provably scoped. +## +## THE ANTI-INERT RULE, stated as a rule every knob is held against: NO +## SETTING OF ANY KNOB, AND NO COMBINATION OF SETTINGS, MAY PRODUCE AN INERT +## OR SELF-STARVING FACTION. The strategy surface lives inside ONE competent +## chassis. Independently of every knob, `bulwark` always: keeps at least one +## archon collecting parts and never lets the stockpile idle above 200 without +## a build order; builds an attacker (soldier or guard) whenever parts allow +## and the attacker census is below its target, and NEVER FEWER THAN THREE +## ATTACKERS PER ARCHON; answers any hostile robot sensed within r2 <= 24 of +## one of its own archons; spends every archon's free repair every turn on the +## weakest damaged friendly in r2 <= 24; never walks its last archon into a +## square adjacent to a den that has zombies queued; and NEVER ATTACKS A +## FRIENDLY SQUARE (friendly fire is legal in 2016 and the chassis never uses +## it). Every knob moves HOW MUCH OF WHAT, WHEN — never WHETHER IT PLAYS. +## `tests/test_bc16_knobs.nim` proves each knob has teeth and +## `tests/test_bc16_survival.nim` proves the floor holds, WITH A NEGATIVE +## CONTROL THAT MUST FAIL (`-d:bc16BrokenChassis`). +## +## THE CHASSIS FILE LAYOUT this table's "what it changes" column points at: +## `chassis/kit.nim` (the shared side memory, the remembered map, the den and +## neutral rosters, the rubble-weighted navigator and the `DecisionOps` +## charging), `chassis/econ.nim` (`plan`, `attackMix`, `queue`), +## `chassis/archon.nim` (`posture`), `chassis/combat.nim`, +## `chassis/micro.nim` (`kite`, `retreat`), `chassis/turret.nim` (`target`, +## `site`), `chassis/dens.nim` (`schedule`), `chassis/neutral.nim` (`plan`), +## `chassis/rubble.nim` (`plan`), `chassis/infect.nim` (`plan`), +## `chassis/comms.nim`, `chassis/bulwark.nim` (the turn dispatcher), +## `chassis/greenhorn.nim` and `chassis/scenario16.nim`. All FOURTEEN exist; +## `NOTICE` and `docs/RULES-BC16.md` name the same paths. + +import std/[json, tables] +import ../../sheet_common +import units + +export sheet_common + +type + Opening16* = enum + ## `econ.nim plan()` — the parts split and the posture for the first 600 + ## rounds, and the three archetypes the 2016 finals actually produced. + opTurtle = "turtle" + opSoldierViperAggro = "soldier_viper_aggro" + opScoutZombiePull = "scout_zombie_pull" + + ZombieKiting16* = enum + ## `micro.nim kite()` — whether a unit backs off a closing zombie instead + ## of trading. It has teeth because the delay table is asymmetric. + zkNever = "never" + zkRangedOnly = "ranged_only" + zkAlways = "always" + + PartsPriority16* = enum + ## `econ.nim queue()` — what the stockpile buys first when it cannot buy + ## everything. + ppUnits = "units" + ppTurrets = "turrets" + ppVipers = "vipers" + + ArchonSpread16* = enum + ## `archon.nim posture()` — where the archons stand relative to each + ## other. + asHuddle = "huddle" + asSpread = "spread" + asSplit = "split" + + NeutralActivation16* = enum + ## `neutral.nim plan()` — whether an archon detours to activate NEUTRALs. + naNever = "never" + naOpportunistic = "opportunistic" + naHunt = "hunt" + + RubbleClear16* = enum + ## `rubble.nim plan()` — this year's terrain, made a spendable choice. + rcNever = "never" + rcPaths = "paths" + rcAggressive = "aggressive" + + InfectionPolicy16* = enum + ## `infect.nim plan()` — this year's largest unexploited play. + ipIgnore = "ignore" + ipQuarantine = "quarantine" + ipSuicideSquad = "suicide_squad" + + Doctrine16* = object + opening*: Opening16 + turretCount*: int + guardRatio*: int + zombieKiting*: ZombieKiting16 + denClearRound*: int + partsPriority*: PartsPriority16 + archonSpread*: ArchonSpread16 + neutralActivation*: NeutralActivation16 + retreatHp*: int + rubbleClear*: RubbleClear16 + infectionPolicy*: InfectionPolicy16 + +const + KnownKeys16* = [ + "opening", "turret_count", "guard_ratio", "zombie_kiting", + "den_clear_round", "parts_priority", "archon_spread", + "neutral_activation", "retreat_hp", "rubble_clear", "infection_policy" + ] + ## Exactly eleven. `chassis` is deliberately NOT here (D1). + + TurretCountLo* = 0 + TurretCountHi* = 12 + GuardRatioLo* = 0 + GuardRatioHi* = 100 + DenClearRoundLo* = 1 + DenClearRoundHi* = 2800 + RetreatHpLo* = 0 + RetreatHpHi* = 100 + + AttackersPerArchonFloor* = 3 + ## The unconditional minimum, at EVERY knob setting. + +proc defaultDoctrine16*(): Doctrine16 = + Doctrine16( + opening: opTurtle, + turretCount: 3, + guardRatio: 45, + zombieKiting: zkRangedOnly, + denClearRound: 900, + partsPriority: ppUnits, + archonSpread: asSpread, + neutralActivation: naOpportunistic, + retreatHp: 35, + rubbleClear: rcPaths, + infectionPolicy: ipQuarantine) + +proc applyKnobs16*(seen: Table[string, JsonNode], + defaultsApplied: var seq[string]): Doctrine16 = + result = defaultDoctrine16() + + template repair(name: string) = + defaultsApplied.add(name) + + template enumKnob(name: string, field: untyped, T: typedesc) = + if name in seen: + if seen[name].kind == JString: + let text = normalizeKey(seen[name].getStr()) + var found = false + for value in T: + if normalizeKey($value) == text: + field = value + found = true + if not found: repair(name) + else: + repair(name) + else: + ## THE ENVELOPE PIN, ITEM 2: an ABSENT known key is counted too, so a + ## seat that played the schema defaults is machine-visible. bc16 and + ## bc22 only. + repair(name) + + template clampedIntKnob(name: string, field: untyped, lo, hi: int) = + ## AN INTEGER KNOB IS CLAMPED, NEVER DEFAULTED, so "as many as possible" + ## still means something. A NON-INTEGER takes the default. + if name in seen: + let n = readNumber(seen[name]) + if n.ok: + let v = int(n.value) + if v < lo or v > hi: + field = max(lo, min(hi, v)) + repair(name) + else: + field = v + else: + repair(name) + else: + repair(name) + + enumKnob("opening", result.opening, Opening16) + clampedIntKnob("turret_count", result.turretCount, + TurretCountLo, TurretCountHi) + clampedIntKnob("guard_ratio", result.guardRatio, + GuardRatioLo, GuardRatioHi) + enumKnob("zombie_kiting", result.zombieKiting, ZombieKiting16) + clampedIntKnob("den_clear_round", result.denClearRound, + DenClearRoundLo, DenClearRoundHi) + enumKnob("parts_priority", result.partsPriority, PartsPriority16) + enumKnob("archon_spread", result.archonSpread, ArchonSpread16) + enumKnob("neutral_activation", result.neutralActivation, + NeutralActivation16) + clampedIntKnob("retreat_hp", result.retreatHp, RetreatHpLo, RetreatHpHi) + enumKnob("rubble_clear", result.rubbleClear, RubbleClear16) + enumKnob("infection_policy", result.infectionPolicy, InfectionPolicy16) + +proc toJson16*(d: Doctrine16): JsonNode = + %*{ + "opening": $d.opening, + "turret_count": d.turretCount, + "guard_ratio": d.guardRatio, + "zombie_kiting": $d.zombieKiting, + "den_clear_round": d.denClearRound, + "parts_priority": $d.partsPriority, + "archon_spread": $d.archonSpread, + "neutral_activation": $d.neutralActivation, + "retreat_hp": d.retreatHp, + "rubble_clear": $d.rubbleClear, + "infection_policy": $d.infectionPolicy + } + +proc bc16SheetSchema*(): JsonNode = + ## The knob surface as the doctrine prompt carries it. Generated from THIS + ## table rather than re-typed, so a knob cannot exist in the sim and be + ## missing from the brief. + let d = defaultDoctrine16() + var openings = newJArray() + for v in Opening16: openings.add(%($v)) + var kitings = newJArray() + for v in ZombieKiting16: kitings.add(%($v)) + var priorities = newJArray() + for v in PartsPriority16: priorities.add(%($v)) + var spreads = newJArray() + for v in ArchonSpread16: spreads.add(%($v)) + var activations = newJArray() + for v in NeutralActivation16: activations.add(%($v)) + var clears = newJArray() + for v in RubbleClear16: clears.add(%($v)) + var infections = newJArray() + for v in InfectionPolicy16: infections.add(%($v)) + %*{ + "opening": {"values": openings, "default": $d.opening, + "note": "the three archetypes the 2016 finals produced. " & + "turtle still builds soldiers -- the attacker " & + "census target is halved, never zeroed"}, + "turret_count": {"range": [TurretCountLo, TurretCountHi], + "default": d.turretCount, + "note": "a turret is 130 parts and 25 turns of a " & + "FROZEN archon, cannot shoot inside " & + "range-squared 6, reaches 40, and must PACK " & + "(10 delay on both counters) to move at all"}, + "guard_ratio": {"range": [GuardRatioLo, GuardRatioHi], + "default": d.guardRatio, + "note": "percent of the ATTACKER budget spent on " & + "GUARDs rather than SOLDIERs (both 30 parts). A " & + "guard has 145 health against 60, deals DOUBLE " & + "damage to zombies and blocks 4 off any hit " & + "above 10; a soldier hits for 4 at " & + "range-squared 13. At 100 the chassis still " & + "builds a soldier whenever no guard is " & + "affordable"}, + "zombie_kiting": {"values": kitings, "default": $d.zombieKiting, + "note": "a STANDARDZOMBIE pays movement delay 3 and a " & + "BIGZOMBIE 4 while a SOLDIER pays 2 -- but a " & + "FASTZOMBIE pays 1.4 and ignores rubble, so " & + "it cannot be kited. The chassis still takes " & + "any attack that KILLS its target at every " & + "setting"}, + "den_clear_round": {"range": [DenClearRoundLo, DenClearRoundHi], + "default": d.denClearRound, + "note": "a den is 2000 health and pays 200 parts, " & + "and killing it deletes its share of every " & + "future wave; it also damages every " & + "adjacent non-zombie for 10 a round while " & + "it has a queue"}, + "parts_priority": {"values": priorities, "default": $d.partsPriority}, + "archon_spread": {"values": spreads, "default": $d.archonSpread, + "note": "archons are the only thing that decides the " & + "game, the only repair source and the only " & + "parts collectors"}, + "neutral_activation": {"values": activations, + "default": $d.neutralActivation, + "note": "an ARCHON activates a NEUTRAL within " & + "range-squared 2 for ZERO parts and 2 " & + "core delay; some maps place neutral " & + "ARCHONS, which are a whole extra " & + "tiebreak rung"}, + "retreat_hp": {"range": [RetreatHpLo, RetreatHpHi], + "default": d.retreatHp, + "note": "percent of max health at which a damaged unit " & + "disengages toward the nearest friendly archon " & + "-- the only healing in the game, 1 health a " & + "turn, free. At every setting the chassis still " & + "takes an attack that KILLS its target"}, + "rubble_clear": {"values": clears, "default": $d.rubbleClear, + "note": "one clear turns r into max(0, 0.95r - 10); " & + "100 or more is impassable to everything but a " & + "SCOUT, a FASTZOMBIE and a BIGZOMBIE; 50 or " & + "more DOUBLES every movement and cooldown " & + "charge; and every uninfected corpse adds its " & + "own max health"}, + "infection_policy": {"values": infections, "default": $d.infectionPolicy, + "note": "anything that dies while infected leaves " & + "NO rubble and stands back up as a zombie " & + "of its own type on the horde's team, and " & + "then hunts whoever is nearest"} + } + +proc plainWords16*(d: Doctrine16): seq[string] = + ## The endcard / `#bc16-doctrines` readout: the sheet in words a spectator + ## can read without knowing the schema. + ## + ## EVERY CLAUSE IS A COMPLETE PHRASE. bc23 shipped `"a accelerating"` + ## because it concatenated `"a "` with an enum string; there is NO ARTICLE + ## CONCATENATION anywhere in this proc, and `tests/test_bc16_sheet.nim` + ## asserts each clause is non-empty and article-free. + case d.opening + of opTurtle: + result.add("holds its archons together behind a guard wall") + of opSoldierViperAggro: + result.add("sends a soldier spearhead at the nearest enemy archon") + of opScoutZombiePull: + result.add("parks scouts between the dens and itself to pull the horde") + if d.turretCount == 0: + result.add("wants no turrets standing, and stays all-mobile") + elif d.turretCount == 1: + result.add("wants one turret standing") + else: + result.add("wants " & $d.turretCount & " turrets standing") + if d.guardRatio == 0: + result.add("builds an all-soldier army") + elif d.guardRatio >= 100: + result.add("builds guards whenever it can afford one") + else: + result.add($d.guardRatio & " % of its army is guards") + case d.zombieKiting + of zkNever: result.add("trades with every zombie where it stands") + of zkRangedOnly: result.add("kites zombies with everything ranged") + of zkAlways: result.add("kites zombies with guards too") + result.add("breaks a zombie den at round " & $d.denClearRound) + case d.partsPriority + of ppUnits: result.add("spends parts on soldiers and guards first") + of ppTurrets: result.add("fills its turret count before any attacker") + of ppVipers: result.add("buys a viper before the third soldier") + case d.archonSpread + of asHuddle: result.add("keeps its archons inside one repair field") + of asSpread: result.add("spreads its archons behind their own screens") + of asSplit: result.add("sends one archon away to farm the far map") + case d.neutralActivation + of naNever: result.add("never detours to activate a neutral") + of naOpportunistic: result.add("activates a neutral it passes") + of naHunt: result.add("routes its archons along the neutral roster") + if d.retreatHp == 0: + result.add("never pulls a wounded unit out of a fight") + elif d.retreatHp >= 100: + result.add("withdraws a unit on the first damage it takes") + else: + result.add("pulls a unit out at " & $d.retreatHp & " % health") + case d.rubbleClear + of rcNever: result.add("clears no rubble and plays the map it was given") + of rcPaths: result.add("clears rubble to open the routes it needs") + of rcAggressive: result.add("flattens its whole home area to full speed") + case d.infectionPolicy + of ipIgnore: result.add("never reads its own infection counters") + of ipQuarantine: + result.add("walks its infected units away from its own archons") + of ipSuicideSquad: + result.add("walks its infected units at the enemy archons to die there") diff --git a/src/battlecode/years/bc16/maps.nim b/src/battlecode/years/bc16/maps.nim new file mode 100644 index 0000000..cc285a7 --- /dev/null +++ b/src/battlecode/years/bc16/maps.nim @@ -0,0 +1,373 @@ +## The converted bc16 map pool, the loader and the per-episode draw. +## +## Maps are read from `data/maps/bc16/.json`, produced by +## `tools/convert_maps_bc16.py` from the official `.xml` maps at the pinned +## `battlecode-server-2016` commit and COMMITTED (CI re-converts and +## byte-diffs). The wasm bundle gets the same directory through emscripten's +## `--preload-file data@data`, so the browser re-derives from exactly the +## bytes the server played — INCLUDING the pre-split per-den schedules, so it +## never has to reproduce a Java `HashMap` (D3). +## +## 22 of the 98 official maps are converted (V7). Every one of the 98 is +## PARSED by CI, and **every map in every pool is one of the 54 the oracle jar +## also carries as a resource**, so no parity pair and no smoke episode needs +## a `--map-dir`. +## +## `mixed` (10 maps) is the `bc16` variant's played pool and spans the axis +## the doctrines argue about: BOTH REACHABLE SYMMETRIES (nine rotational, one +## horizontal); 1 080 to 2 025 squares; ARCHONS PER SIDE 2, 3 AND 4; dens per +## side from 1 (`turtle`) to 3 (`collision`); parts from 1 640 on 110 squares +## (`caverns`) to 20 520 on 684 (`quadrants`), and `turtle`'s 1 800 on SIX +## squares of 300 apiece; rubble means from 71.9 (`industrial`) to 1 263.5 +## (`boxy`), and `caverns` with 1 078 of 1 892 squares ALREADY IMPASSABLE; +## first waves from round 0 to round 300; schedules from 9 to 17 rounds and +## 204 to 378 zombies; and neutral rosters from 0 to 26 including two neutral +## ARCHONS. One map would rank the map, not the doctrine. +## +## `small` (6) is the pool the parity oracle and the docker smoke run on; +## `large` (6) is reserved for a later variant and supplies two of the nine +## parity pairs. + +import std/[json, math, os, strutils] +import ../../sim_types +import world, economy + +export world + +const + SmallPool* = ["checkers", "zigzag", "swamp", "river", "prisons", "frogger"] + MixedPool* = ["closequarters", "lockdown", "industrial", "quadrants", + "turtle", "boxy", "voluted", "collision", "caverns", "6147"] + LargePool* = ["desert", "space", "scouting", "vortex", "wormy", "quarry"] + + ParityPairs* = ["checkers", "zigzag", "swamp", "river", "prisons", + "frogger", "turtle", "desert", "space"] + ## The nine Tier A/A'/A" pairs, chosen to cover every branch of the two + ## rules that have branches: both reachable symmetries and both + ## chiralities (`frogger` VERTICAL, `turtle` HORIZONTAL, the rest + ## ROTATIONAL), a two-symmetry map where the engine's first-wins order + ## decides, the rubble boundary at exactly 200 (`checkers`) and the + ## extreme at 10^6 (`zigzag`), minimum (`river`, 4.0) and maximum + ## (`desert`, 75) archon separation, one archon a side (`swamp`), four a + ## side (`frogger`, `space`), two dens (`prisons`, `turtle`) and ten + ## (`desert`), neutral ARCHONS (`prisons`), and a map with ZERO impassable + ## squares as the control (`frogger`). + +proc poolNames*(pool: string): seq[string] = + case pool.toLowerAscii() + of "small": @SmallPool + of "large": @LargePool + of "mixed", "": @MixedPool + else: @[] + +proc parseSymmetry(text: string): Symmetry = + case text + of "vertical": symVertical + of "horizontal": symHorizontal + of "rotational": symRotational + of "negative_diagonal": symNegativeDiagonal + of "positive_diagonal": symPositiveDiagonal + else: symNone + +proc zombieCounts(node: JsonNode): array[4, int] = + ## The converted file names the four zombie types; the array is indexed by + ## `ZombieSpawnTypes` position, which is `RobotType` ordinal order. + for i, kind in ZombieSpawnTypes: + result[i] = node{$kind}.getInt(0) + +proc parseMapSpec*(text: string): MapSpec = + let doc = parseJson(text) + result.name = doc["name"].getStr() + result.width = doc["width"].getInt() + result.height = doc["height"].getInt() + result.randomSeed = doc["random_seed"].getInt() + result.rounds = doc{"rounds"}.getInt(GameDefaultRounds) + result.symmetry = parseSymmetry(doc["symmetry"].getStr()) + for s in doc{"symmetries_found"}: + result.symmetriesFound.add(parseSymmetry(s.getStr())) + if result.width < MapMinWidth or result.width > MapMaxWidth or + result.height < MapMinHeight or result.height > MapMaxHeight: + raise newException(ConfigError, + "bc16 map " & result.name & " is " & $result.width & "x" & + $result.height & ", outside 30..80") + let expected = result.width * result.height + result.rubble = newSeq[float64](expected) + result.parts = newSeq[float64](expected) + ## Both arrays are `[y][x]` rows, exactly as `GameMap` holds them, and are + ## flattened to `y * width + x` — `SquareArray.Double`'s own indexing (D5), + ## so the two checksums line up. + let rubbleRows = doc["rubble"] + let partsRows = doc["parts"] + if rubbleRows.len != result.height or partsRows.len != result.height: + raise newException(ConfigError, + "bc16 map " & result.name & " has " & $rubbleRows.len & + " rubble rows, expected " & $result.height) + for y in 0 ..< result.height: + if rubbleRows[y].len != result.width or partsRows[y].len != result.width: + raise newException(ConfigError, + "bc16 map " & result.name & " row " & $y & " is not " & + $result.width & " wide") + for x in 0 ..< result.width: + result.rubble[y * result.width + x] = rubbleRows[y][x].getFloat() + result.parts[y * result.width + x] = partsRows[y][x].getFloat() + for b in doc["initial_robots"]: + result.initialRobots.add((x: b[0].getInt(), y: b[1].getInt(), + kind: b[2].getInt(), team: b[3].getInt())) + for row in doc["schedule"]: + result.schedule.add((round: row["round"].getInt(), + counts: zombieCounts(row["counts"]))) + for den in doc["dens"]: + var spec = DenSpec(x: den["x"].getInt(), y: den["y"].getInt(), + spawnDir: den["spawn_dir"].getInt(), + chirality: den["chirality"].getInt()) + var rounds: seq[int] + for key, _ in den["schedule"]: + rounds.add(parseInt(key)) + ## Ascending, because `ZombieSpawnSchedule.getRounds()` sorts and the den + ## queue is filled in that order. + for i in 1 ..< rounds.len: + let v = rounds[i] + var j = i - 1 + while j >= 0 and rounds[j] > v: + rounds[j + 1] = rounds[j] + dec j + rounds[j + 1] = v + for r in rounds: + spec.schedule.add((round: r, + counts: zombieCounts(den["schedule"][$r]))) + result.dens.add(spec) + +proc mapPath*(name: string): string = + dataRoot() / "maps" / "bc16" / (name & ".json") + +proc loadMap*(name: string): MapSpec = + let path = mapPath(name) + if not fileExists(path): + raise newException(ConfigError, "no converted bc16 map at " & path) + parseMapSpec(readFile(path)) + +proc drawMaps*(pool: string, seed, count: int): seq[string] = + ## `count` DISTINCT maps from the pool, chosen by successive seed-derived + ## indices. Identical in shape to the seven shipped years' draws, so the + ## eight years rank the same way for the same seed. + let names = poolNames(pool) + var remaining = names + var s = uint32(seed) xor 0x9E3779B9'u32 + for i in 0 ..< min(count, remaining.len): + s = s * 1664525'u32 + 1013904223'u32 + let pick = int(s shr 16) mod remaining.len + result.add(remaining[pick]) + remaining.delete(pick) + +proc sideAslotFor*(seed, gameIndex: int): int = + ## `(seed shr 8) and 1` picks which SEAT takes side A in game 1; sides + ## alternate every game after that. + ((seed shr 8) and 1) xor (gameIndex and 1) + +# --------------------------------------------------------------------------- +# Map cards — the per-map facts a seat may legitimately know +# --------------------------------------------------------------------------- + +proc archonsOf*(spec: MapSpec, team: Team): seq[JsonNode] = + ## The initial archons are PUBLIC: `getInitialArchonLocations` is free to + ## every robot in the real game (`RobotControllerImpl.java:101-118`), so + ## hiding them would hide nothing and cost the doctrine its opening. + for b in spec.initialRobots: + if b.kind != ord(rtArchon): continue + if b.team != ord(team): continue + result.add(%*{"x": b.x, "y": b.y, + "rubble": int(spec.rubble[b.x + b.y * spec.width])}) + +proc archonsPerSide*(spec: MapSpec): int = spec.archonsOf(teamA).len + +proc densOf*(spec: MapSpec): seq[JsonNode] = + for d in spec.dens: + result.add(%*{"x": d.x, "y": d.y}) + +proc neutralRoster*(spec: MapSpec): JsonNode = + result = newJObject() + for b in spec.initialRobots: + if b.team != ord(teamNeutral): continue + let key = ($RobotType(b.kind)).toLowerAscii() + result[key] = %(result{key}.getInt(0) + 1) + +proc neutralTotal*(spec: MapSpec): int = + for b in spec.initialRobots: + if b.team == ord(teamNeutral): result += 1 + +proc startSeparation*(spec: MapSpec): float64 = + ## The shortest Euclidean distance between an A archon and a B archon: the + ## one number that says how far a soldier rush has to run. `river`'s 4.0 is + ## the minimum in the pool and `desert`'s 75 the maximum. + var best = high(float64) + for a in spec.initialRobots: + if a.kind != ord(rtArchon) or a.team != ord(teamA): continue + for b in spec.initialRobots: + if b.kind != ord(rtArchon) or b.team != ord(teamB): continue + let d = float64((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y)) + best = min(best, sqrt(d)) + if best == high(float64): 0.0 else: best + +proc rubbleMean*(spec: MapSpec): float64 = + if spec.rubble.len == 0: return 0.0 + var total = 0.0 + for v in spec.rubble: total += v + total / float64(spec.rubble.len) + +proc rubbleMax*(spec: MapSpec): float64 = + for v in spec.rubble: + if v > result: result = v + +proc impassableSquares*(spec: MapSpec): int = + for v in spec.rubble: + if v >= RubbleObstructionThresh: result += 1 + +proc squaresOver50Pct*(spec: MapSpec): float64 = + var n = 0 + for v in spec.rubble: + if v >= RubbleSlowThresh: n += 1 + if spec.rubble.len == 0: 0.0 + else: round(float64(n) / float64(spec.rubble.len) * 1000.0) / 10.0 + +proc partsSquares*(spec: MapSpec): int = + for v in spec.parts: + if v > 0.0: result += 1 + +proc partsTotal*(spec: MapSpec): int = + var total = 0.0 + for v in spec.parts: total += v + int(total) + +proc partsMaxSquare*(spec: MapSpec): int = + var best = 0.0 + for v in spec.parts: + if v > best: best = v + int(best) + +proc nearestParts*(spec: MapSpec, from0: JsonNode): JsonNode = + ## The nearest parts deposit to an archon, by Chebyshev steps — the walking + ## distance an archon actually pays, because movement is eight-directional. + if from0.isNil: return newJNull() + let fx = from0["x"].getInt() + let fy = from0["y"].getInt() + var best = high(int) + var bx, by = 0 + var amount = 0.0 + for i in 0 ..< spec.parts.len: + if spec.parts[i] <= 0.0: continue + let x = i mod spec.width + let y = i div spec.width + let steps = max(abs(x - fx), abs(y - fy)) + if steps < best: + best = steps + bx = x + by = y + amount = spec.parts[i] + if best == high(int): return newJNull() + %*{"x": bx, "y": by, "amount": int(amount), "steps": best} + +proc nearestNeutral*(spec: MapSpec, from0: JsonNode): JsonNode = + if from0.isNil: return newJNull() + let fx = from0["x"].getInt() + let fy = from0["y"].getInt() + var best = high(int) + var found = false + var bx, by = 0 + var kind = rtSoldier + for b in spec.initialRobots: + if b.team != ord(teamNeutral): continue + let steps = max(abs(b.x - fx), abs(b.y - fy)) + if steps < best: + best = steps + bx = b.x + by = b.y + kind = RobotType(b.kind) + found = true + if not found: return newJNull() + %*{"x": bx, "y": by, "type": ($kind).toLowerAscii(), "steps": best} + +proc scheduleJson*(spec: MapSpec): JsonNode = + ## The WHOLE-MAP schedule, which `getZombieSpawnSchedule()` exposes free to + ## every robot. THE PER-DEN SPLIT IS NOT EXPOSED BY ANY 2016 API and is + ## therefore not in the card. + result = newJArray() + for row in spec.schedule: + var entry = %*{"round": row.round} + for i, kind in ZombieSpawnTypes: + if row.counts[i] > 0: + entry[($kind).toLowerAscii()] = %row.counts[i] + result.add(entry) + +proc zombiesPerDen*(spec: MapSpec): int = + if spec.dens.len == 0: return 0 + for row in spec.dens[0].schedule: + for c in row.counts: result += c + +proc mapCard*(spec: MapSpec, slot, sideAslot: int): JsonNode = + ## Every map is symmetric, so both seats' cards are numerically identical; + ## `you_are` and which mirrored coordinate set is labelled "yours" are the + ## only asymmetries. + let side = if sideAslot == slot: teamA else: teamB + var mine = newJArray() + let mineSeq = spec.archonsOf(side) + for a in mineSeq: mine.add(a) + var theirs = newJArray() + for a in spec.archonsOf(side.other()): + theirs.add(%*{"x": a["x"].getInt(), "y": a["y"].getInt()}) + var denLocs = newJArray() + for d in spec.densOf(): denLocs.add(d) + let firstMine = if mine.len > 0: mine[0] else: nil + %*{ + "map": spec.name, + "width": spec.width, + "height": spec.height, + "symmetry": $spec.symmetry, + "you_are": (if sideAslot == slot: "A" else: "B"), + "rounds_are_zero_based": true, + "your_archons": mine, + "enemy_archons": theirs, + "start_separation": round(spec.startSeparation() * 10.0) / 10.0, + "terrain": { + "rubble_mean": round(spec.rubbleMean() * 10.0) / 10.0, + "rubble_max": int(spec.rubbleMax()), + "impassable_squares": spec.impassableSquares(), + "total_squares": spec.width * spec.height, + "squares_over_50_rubble_pct": spec.squaresOver50Pct(), + "note": "a square is passable only if its rubble is UNDER 100, " & + "except for SCOUTs, FASTZOMBIEs and BIGZOMBIEs which ignore " & + "rubble entirely; rubble of 50 or more DOUBLES every movement " & + "and cooldown charge" + }, + "parts": { + "squares": spec.partsSquares(), + "total": spec.partsTotal(), + "max_square": spec.partsMaxSquare(), + "nearest_to_you": spec.nearestParts(firstMine), + "note": "only an ARCHON collects parts, and it takes the WHOLE square " & + "by standing on it or moving onto it" + }, + "dens": { + "count": spec.dens.len, + "per_side": spec.dens.len div 2, + "health_each": int(RobotSpecs[rtZombieden].maxHealth), + "bounty_each": int(DenPartReward), + "locations": denLocs, + "zombies_queued_each_over_the_game": spec.zombiesPerDen() + }, + "neutrals": { + "total": spec.neutralTotal(), + "by_type": spec.neutralRoster(), + "nearest_to_you": spec.nearestNeutral(firstMine), + "note": "an ARCHON activates a NEUTRAL within radius-squared 2 for " & + "zero parts and 2 core delay; the neutral is replaced by an " & + "identical robot on your team, immediately active. A neutral " & + "ARCHON is a whole extra tiebreak rung." + }, + "zombie_schedule": spec.scheduleJson(), + "schedule_note": "these are WHOLE-MAP counts, divided as evenly as " & + "possible among the " & $spec.dens.len & " dens; a den " & + "spawns at most 8 zombies per attempt and 16 per " & + "round, and if it still has a queue it damages every " & + "adjacent non-zombie for 10 first", + "tiebreak_round": spec.rounds - 1 + } diff --git a/src/battlecode/years/bc16/rules.nim b/src/battlecode/years/bc16/rules.nim new file mode 100644 index 0000000..285c256 --- /dev/null +++ b/src/battlecode/years/bc16/rules.nim @@ -0,0 +1,557 @@ +## The bc16 round loop, the four-rung end ladder, the points formula and one +## game. +## +## `runRound` mirrors `GameWorld.runRound` / `processBeginningOfRound` / +## `processEndOfRound` step for step, and THE STEP LIST IS THE RULES: a +## re-ordering is a rules change and bumps `GameVersion` +## (docs/RULES-BC16.md §The round loop). +## +## 1. beginning of round: `currentRound += 1` — FROM -1, so the first round +## played is round 0 and the last is 2999. Then every robot's +## `processBeginningOfRound`, whose body in 2016 is EMPTY (`:431-432`), +## and `controlProvider.roundStarted()`, which is empty in both providers. +## Both are genuine no-ops with no observable effect and are therefore +## NOT PORTED AT ALL (D1) — and both iterate a `LinkedHashMap` anyway, so +## even their order is insertion order. +## 2. the turn order: a SNAPSHOT of the insertion-ordered id list taken +## BEFORE the sweep, with a `robot == null` guard, so a robot built this +## round takes NO turn this round and a robot destroyed mid-sweep is +## skipped. +## 3. each robot's turn, in four parts: +## a. `processBeginningOfTurn`: `decrementDelays()` (V1: exactly 1.0 off +## both counters, each floored at 0), `repairCount = 0`, +## `basicSignalCount = 0`, `messageSignalCount = 0`, and the +## `DecisionOps` budget reset — to ZERO for a robot with +## `!isActive()`, which is how a soldier built this round is a live, +## blocking, damageable robot that does nothing for 12 turns; +## b. run the controller: a PLAYER robot runs its team's chassis under +## its doctrine; a ZOMBIE or a DEN runs the engine's own +## `ZombieControlProvider` logic, which IS the sim and costs nothing +## against any budget; a NEUTRAL robot does nothing; +## c. record the ops used (telemetry only — NO RULE READS IT, which is +## what V1 buys); +## d. `processEndOfTurn`, AND ONLY IF `health > 0`: `roundsAlive += 1` +## then `processBeingInfected()` — the viper strain's 2.0 damage, +## which can kill, and then the robot IS infected, so it becomes a +## zombie — and finally the disintegrate suicide. +## 4. end of round, in exactly this order: +## a. every robot's `processEndOfRound` — EMPTY in 2016 (`:459`), a +## genuine no-op (D1); +## b. parts income: A's stockpile `+= max(0, 2 - 0.01 * robots)`, then +## B's; +## c. the end-of-match check, if `timeLimitReached()` AND no winner is +## set: the four-rung ladder, first non-zero difference wins, on +## EXACT FLOAT64 DIFFERENCES; +## d. `running = false` if a winner is set; then the state hash. + +import std/[monotimes, strutils, times] +import ../../sim_types +import ../../sheet +import world, economy, signals, zombies, maps, knobs +import chassis/[kit, bulwark, greenhorn, scenario16] + +export world, economy, signals, zombies, maps, knobs, kit + +type + ChassisKind16* = enum + ckBulwark = "bulwark" + ckGreenhorn = "greenhorn" + + GameOutcome16* = object + index*: int + mapName*: string + sideAslot*: int ## which SEAT plays team A this game + roundsPlayed*: int + winnerSlot*: int ## -1 = no winner recorded (abandoned) + endReason*: string + points*: array[2, int] ## BY SEAT + hashChain*: string + roundChains*: string + aborted*: bool + ## Per-game statistics, BY SEAT — the optional year-specific siblings in + ## `results.games[]`. + archonsStart*: array[2, int] + archonsEnd*: array[2, int] + archonsLost*: array[2, int] + archonHealthEndTenths*: array[2, int] + partsEndTenths*: array[2, int] + partsWorthEnd*: array[2, int] + partsCollectedTenths*: array[2, int] + partsIncomeTenths*: array[2, int] + partsSpentTenths*: array[2, int] + unitsBuilt*: array[2, int] + scoutsBuilt*: array[2, int] + soldiersBuilt*: array[2, int] + guardsBuilt*: array[2, int] + vipersBuilt*: array[2, int] + turretsBuilt*: array[2, int] + turretPacks*: array[2, int] + robotsAlive*: array[2, int] + robotsLost*: array[2, int] + robotsTurned*: array[2, int] + neutralsActivated*: array[2, int] + neutralArchonsActivated*: array[2, int] + densDestroyed*: array[2, int] + denDamageDealt*: array[2, int] + damageDealt*: array[2, int] + zombieDamageDealt*: array[2, int] + zombieDamageTaken*: array[2, int] + enemyDamageDealt*: array[2, int] + enemyDamageTaken*: array[2, int] + infectionsSuffered*: array[2, int] + infectionsInflicted*: array[2, int] + viperInfectionDamage*: array[2, int] + repairs*: array[2, int] + hpRepaired*: array[2, int] + rubbleClearedTenths*: array[2, int] + rubbleCreatedTenths*: array[2, int] + squaresOpened*: array[2, int] + basicSignals*: array[2, int] + messageSignals*: array[2, int] + archonPartsWalks*: array[2, int] + archonsAliveAt2000*: array[2, int] + ## Scalars. + archonsPerSide*: int + densPerSide*: int + densOnMap*: int + partsOnMapStart*: int + partsSquaresStart*: int + rubbleMeanTenths*: int + impassableSquaresStart*: int + impassableSquaresEnd*: int + neutralsOnMapStart*: int + zombiesSpawned*: int + zombiesAliveEnd*: int + zombiesKilled*: int + outbreakLevelEnd*: int + scheduleRounds*: int + tiebreakRound*: int + +proc chassisFromName*(name: string): ChassisKind16 = + case name.strip().toLowerAscii() + of "greenhorn", "scaffold", "example", "examplefuncsplayer": + ckGreenhorn + else: ckBulwark + +proc chassisKindFor*(sc: ScriptedChassis): ChassisKind16 = + ## The year-neutral `ScriptedChassis` mapped into bc16's own kind. A name + ## belonging to another year falls back to bc16's STRONG chassis, so a bc22 + ## name on a bc16 game plays `bulwark` rather than nothing. + case sc + of scGreenhorn: ckGreenhorn + else: ckBulwark + +proc slotOf*(outcome: GameOutcome16, team: Team): int = + if team == teamA: outcome.sideAslot else: 1 - outcome.sideAslot + +proc newSides16*(sheets: array[2, Sheet], sideAslot: int): array[2, Side] = + ## `sides[ord(team)]`. Which SEAT is behind team A alternates per game. + result[0] = newSide(teamA, sheets[sideAslot].doctrine16) + result[1] = newSide(teamB, sheets[1 - sideAslot].doctrine16) + +proc runControllerFor*(w: World, sides: array[2, Side], + chassis: array[2, ChassisKind16], r: Robot) = + ## Rule 3.2. A ZOMBIE or a DEN is the SIM, not a chassis: it runs the + ## ported `ZombieControlProvider` and costs nothing against any budget. A + ## NEUTRAL robot takes its turn and does nothing. + if r.team == teamZombie: + w.runZombieController(r) + return + if r.team == teamNeutral: + return + if not r.canExecuteCode(): + ## `getBytecodeLimit()` returns 0 for a robot that cannot execute code, + ## so its controller cannot do anything at all. + return + when defined(bc16Scenario): + runScenario16(w, r) + else: + let side = sides[ord(r.team)] + case chassis[ord(r.team)] + of ckBulwark: runBulwark(w, side, r) + of ckGreenhorn: runGreenhorn(w, r) + +# --------------------------------------------------------------------------- +# The four-rung end ladder, in the engine's own order +# --------------------------------------------------------------------------- + +func timeLimitReached*(w: World): bool = + ## `GameWorld.timeLimitReached` is `currentRound >= gameMap.getRounds() - 1`, + ## and every official map declares `rounds = 3000`, so it fires at the END + ## OF ROUND 2999 — round 2999 IS PLAYED. + w.currentRound >= w.maxRounds - 1 + +proc setWinnerIfNonzero(w: World, n: float64, d: Domination): bool = + ## `GameWorld.setWinnerIfNonzero`: `n > 0 -> A`, `n < 0 -> B`, and the + ## return value is `n != 0`. EXACT FLOAT64 comparisons. + if n > 0.0: w.setWinner(teamA, d) + elif n < 0.0: w.setWinner(teamB, d) + n != 0.0 + +proc checkEndOfMatch*(w: World) = + ## `processEndOfRound`'s ladder (`:634-679`), first non-zero difference + ## wins: + ## 1 more ARCHONs -> PWNED + ## 2 greater total live-archon health -> OWNED + ## 3 greater `parts + sum(partCost)` -> BARELY_BEAT + ## 4 higher maximum live archon id -> WON_BY_DUBIOUS_REASONS, + ## and the `else` branch awards B — so a 0-vs-0 tie goes to B. + ## + ## Rung 3's accumulator is SEEDED with the parts difference and then walks + ## every live robot of either team ONCE, in insertion order + ## (`partsNetWorthDiff`), exactly as the engine does. + if w.timeLimitReached() and not w.hasWinner: + let archonDiff = float64(w.archonsAlive(teamA) - w.archonsAlive(teamB)) + if not w.setWinnerIfNonzero(archonDiff, dfPwned): + let healthDiff = w.archonHealthTotal(teamA) - w.archonHealthTotal(teamB) + let partsDiff = w.partsNetWorthDiff() + if not w.setWinnerIfNonzero(healthDiff, dfOwned) and + not w.setWinnerIfNonzero(partsDiff, dfBarelyBeat): + if w.highestArchonId(teamA) > w.highestArchonId(teamB): + w.setWinner(teamA, dfDubious) + else: + w.setWinner(teamB, dfDubious) + w.tiebreakRung = ord(w.domination) + discard w.beat(BeatTiebreak, "tiebreak", ord(w.domination), + w.archonsAlive(teamA) * 100 + w.archonsAlive(teamB), + w.partsWorth(teamA) * 100000 + w.partsWorth(teamB), + $int(w.archonHealthTotal(teamA) * 10.0) & ":" & + $int(w.archonHealthTotal(teamB) * 10.0)) + if w.hasWinner: + w.running = false + +# --------------------------------------------------------------------------- +# Scoring +# --------------------------------------------------------------------------- + +func share*(x, y: int): float32 = + ## The bc22-bc25 choice, and bc16 keeps it: a 0-0 total is 0.5, NOT 0. Two + ## factions that both ended with no archons should not be separated by an + ## arithmetic accident, and on this year's evidence a double annihilation + ## by the horde is a real outcome. + if x + y == 0: 0.5'f32 else: float32(x) / float32(x + y) + +proc gamePoints*(w: World): array[2, int] = + ## A continuous reading of the engine's OWN tiebreak ladder, in its own + ## priority order and weighted in that order: archons 64, archon health 24, + ## parts net worth 12. + ## + ## Rungs 2 and 3 are float64 in the engine and are NARROWED TO INTEGERS + ## here — archon health to TENTHS, parts worth to a truncated integer — + ## before any share is taken, and every share is then narrowed through + ## FLOAT32 with the weighted sum TRUNCATED by the `int()` cast. That is + ## deliberate: `points` must be reproducible bit for bit between the native + ## recorder and the wasm re-deriver, and a float64 sum reduced in a + ## different order would not be. + ## + ## THE `end_reason` IS NOT COMPUTED THIS WAY: the ladder uses the engine's + ## exact float64 differences, so a razor-thin margin can decide the WINNER + ## on a difference that rounds away in POINTS. Stated explicitly, tested + ## explicitly (`tests/test_bc16_scoring.nim`), and not a bug. + ## + ## The weights are SUPER-INCREASING (`24 > 12` and `64 > 24 + 12`), so a + ## DECISIVE margin on a higher rung dominates everything below it. THIS + ## CLAIMS NO MORE THAN THAT: a one-unit margin on a rung with large totals + ## gives an arbitrarily small advantage (4 vs 3 archons is + ## `4/7 - 3/7 = 0.143`, i.e. 9.1 points against 36 available below), so + ## `points` ALONE CAN FAVOUR THE LOSER. It measures the SHAPE of the game, + ## not who won it; `results.scores` adds 200 per game won and IS + ## win-dominated by construction. + let archons = [w.archonsAlive(teamA), w.archonsAlive(teamB)] + let archonHp = [int(10.0 * w.archonHealthTotal(teamA)), + int(10.0 * w.archonHealthTotal(teamB))] + let worth = [w.partsWorth(teamA), w.partsWorth(teamB)] + for t in 0 .. 1: + let o = 1 - t + result[t] = int(64.0'f32 * share(archons[t], archons[o]) + + 24.0'f32 * share(archonHp[t], archonHp[o]) + + 12.0'f32 * share(worth[t], worth[o])) + +# --------------------------------------------------------------------------- +# One round +# --------------------------------------------------------------------------- + +proc processBeginningOfTurn(w: World, r: Robot) = + ## Rule 3.1. `decrementDelays()` runs for EVERY robot including one that + ## cannot act, which is why a robot's delays keep draining while it is + ## being built. + r.d.decrementDelays() + r.repairCount = 0 + r.basicSignalCount = 0 + r.messageSignalCount = 0 + r.opsLeft = (if r.canExecuteCode(): budgetFor(r.kind) else: 0) + r.opsUsed = 0 + +proc processEndOfTurn(w: World, r: Robot) = + ## Rule 3.4, and ONLY when `health > 0` — the caller checks that. + r.roundsAlive += 1 + if r.opsUsed > w.opsUsedPeak: w.opsUsedPeak = r.opsUsed + let damage = r.inf.tickInfection() + if damage > 0.0: + if r.team.isPlayer(): + w.stats.viperInfectionDamage[ord(r.team)] += int(damage) + w.changeHealthLevel(r, -damage, dcNormal) + +proc emitRoundBeats(w: World) = + ## The beats that read the round's own deltas, all bounded per game. + for row in w.turnedThisRound: + discard w.beat(BeatTurned, "turned", row.team, ord(row.kind), + row.l.x * 100 + row.l.y, + ($row.became).toLowerAscii() & ":" & + $outbreakLevel(w.currentRound)) + w.turnedThisRound.setLen(0) + for row in w.archonLostThisRound: + discard w.beat(BeatArchonLost, "archon_lost", row.team, + w.archonsAlive(Team(row.team)), 0, row.cause) + w.archonLostThisRound.setLen(0) + if w.attackersLostThisRound[0] > 0 and w.attackersLostThisRound[1] > 0: + discard w.beat(BeatDuel, "duel", w.attackersLostThisRound[0], + w.attackersLostThisRound[1]) + for t in 0 .. 1: + if w.lostThisRound[t] >= 5: + discard w.beat(BeatRout, "rout", t, w.lostThisRound[t]) + +proc emitScheduleBeats(w: World) = + ## `zombie_wave` on every scheduled round, and `outbreak` on every 300th. + for row in w.map.schedule: + if row.round == w.currentRound: + var total = 0 + for c in row.counts: total += c + discard w.beat(BeatZombieWave, "zombie_wave", total, + w.densStanding(), outbreakLevel(w.currentRound), + $row.counts[0] & ":" & $row.counts[1] & ":" & + $row.counts[2] & ":" & $row.counts[3]) + if w.currentRound > 0 and (w.currentRound mod OutbreakTimer) == 0: + discard w.beat(BeatOutbreak, "outbreak", outbreakLevel(w.currentRound), + int(outbreakMultiplier(w.currentRound) * 1000.0)) + +proc runRound*(w: World, sides: array[2, Side], + chassis: array[2, ChassisKind16]) = + ## Rule 1a. `currentRound++` from -1. + inc w.currentRound + ## Rules 1b and 1c are GENUINE NO-OPS in 2016 and are not ported (D1). + + ## THE CHASSIS'S ROUND-LEVEL BOOKKEEPING RUNS FIRST, so every robot this + ## round reads the same census and the same den programme. + when not defined(bc16Scenario): + for t in 0 .. 1: + case chassis[t] + of ckBulwark: beginRound(w, sides[t]) + of ckGreenhorn: discard + for t in 0 .. 1: + w.lostThisRound[t] = 0 + w.attackersLostThisRound[t] = 0 + + w.emitScheduleBeats() + + ## Rules 2 and 3. THE ARRAY BEING ITERATED IS A SNAPSHOT taken before the + ## sweep (`gameObjectsByID.keySet().stream()...toArray()`), so a robot + ## built this round does NOT take a turn this round, and a robot destroyed + ## mid-sweep is skipped by the `robot == null` guard. + let snapshot = w.execOrder + for id in snapshot: + if not w.existsRobot(id): continue + let r = w.robotsById[id] + w.processBeginningOfTurn(r) + w.runControllerFor(sides, chassis, r) + if w.existsRobot(id) and r.health > 0.0: + w.processEndOfTurn(r) + ## `runRound:178-181`: a robot that terminated is suicided AFTER + ## `processEndOfTurn`, as an ordinary death signal — so a robot that + ## disintegrates WHILE INFECTED still becomes an enemy zombie. + if w.existsRobot(id) and r.disintegrated: + w.visitDeathSignal(r, dcNormal) + + ## Rule 4a is a GENUINE NO-OP in 2016 (D1). Rule 4b: + w.addPartsIncome() + ## Rule 4c/4d. + w.emitRoundBeats() + if w.currentRound == 2000: + for t in 0 .. 1: + w.stats.archonsAliveAt2000[t] = w.archonsAlive(Team(t)) + w.checkEndOfMatch() + + ## The per-round hash chain: FIFTEEN per-team values plus ELEVEN globals, + ## so a re-derivation that diverged in only one of them cannot reproduce the + ## chain (the GV02 lesson). Folding the TWO RNG STATES is a bc16-specific + ## decision and it is the cheapest possible tripwire for a missed or extra + ## draw (D2b/D2c). + for t in 0 .. 1: + let team = Team(t) + w.mixHash(w.archonsAlive(team)) + w.mixHash(w.robotTypeCount(team, rtScout) * 1000000 + + w.robotTypeCount(team, rtSoldier) * 10000 + + w.robotTypeCount(team, rtGuard) * 100 + + w.robotTypeCount(team, rtViper)) + w.mixHash(w.robotTypeCount(team, rtTurret) * 100 + + w.robotTypeCount(team, rtTtm)) + w.mixHash(w.totalHealthTenths(team)) + w.mixHash(int(w.archonHealthTotal(team) * 10.0)) + w.mixHash(int(w.resources[t] * 10.0)) + w.mixHash(w.partsWorth(team)) + w.mixHash(w.infectedCount(team)) + w.mixHash(w.stats.robotsLost[t]) + w.mixHash(w.stats.partsCollectedTenths[t]) + w.mixHash(w.stats.densDestroyed[t]) + w.mixHash(w.stats.neutralsActivated[t]) + w.mixHash(w.stats.robotsTurned[t]) + w.mixHash(w.stats.rubbleClearedTenths[t]) + w.mixHash(w.stats.damageDealt[t]) + w.mixHash(w.currentRound) + w.mixHashU(w.rubbleChecksum()) + w.mixHashU(w.partsChecksum()) + w.mixHashU(w.execOrderChecksum()) + w.mixHash(w.execOrder.len) + w.mixHash(w.zombieCountByType(rtStandardzombie) * 1000000 + + w.zombieCountByType(rtRangedzombie) * 10000 + + w.zombieCountByType(rtFastzombie) * 100 + + w.zombieCountByType(rtBigzombie)) + w.mixHash(w.densStanding()) + w.mixHash(w.neutralsStanding()) + w.mixHashU(cast[uint64](w.rand.seed)) + w.mixHashU(cast[uint64](w.zombieRand.seed)) + +# --------------------------------------------------------------------------- +# One game +# --------------------------------------------------------------------------- + +proc endReasonFor(w: World): string = + case w.domination + of dfNone: $dfPwned + else: $w.domination + +proc harvest(w: World, outcome: var GameOutcome16) = + for team in [teamA, teamB]: + let t = ord(team) + let slot = outcome.slotOf(team) + outcome.archonsStart[slot] = w.stats.archonsStart[t] + outcome.archonsEnd[slot] = w.archonsAlive(team) + outcome.archonsLost[slot] = w.stats.archonsLost[t] + outcome.archonHealthEndTenths[slot] = int(w.archonHealthTotal(team) * 10.0) + outcome.partsEndTenths[slot] = int(w.resources[t] * 10.0) + outcome.partsWorthEnd[slot] = w.partsWorth(team) + outcome.partsCollectedTenths[slot] = w.stats.partsCollectedTenths[t] + outcome.partsIncomeTenths[slot] = w.stats.partsIncomeTenths[t] + outcome.partsSpentTenths[slot] = w.stats.partsSpentTenths[t] + outcome.unitsBuilt[slot] = w.stats.unitsBuilt[t] + outcome.scoutsBuilt[slot] = w.stats.scoutsBuilt[t] + outcome.soldiersBuilt[slot] = w.stats.soldiersBuilt[t] + outcome.guardsBuilt[slot] = w.stats.guardsBuilt[t] + outcome.vipersBuilt[slot] = w.stats.vipersBuilt[t] + outcome.turretsBuilt[slot] = w.stats.turretsBuilt[t] + outcome.turretPacks[slot] = w.stats.turretPacks[t] + outcome.robotsAlive[slot] = w.robotCountOf(team) + outcome.robotsLost[slot] = w.stats.robotsLost[t] + outcome.robotsTurned[slot] = w.stats.robotsTurned[t] + outcome.neutralsActivated[slot] = w.stats.neutralsActivated[t] + outcome.neutralArchonsActivated[slot] = + w.stats.neutralArchonsActivated[t] + outcome.densDestroyed[slot] = w.stats.densDestroyed[t] + outcome.denDamageDealt[slot] = w.stats.denDamageDealt[t] + outcome.damageDealt[slot] = w.stats.damageDealt[t] + outcome.zombieDamageDealt[slot] = w.stats.zombieDamageDealt[t] + outcome.zombieDamageTaken[slot] = w.stats.zombieDamageTaken[t] + outcome.enemyDamageDealt[slot] = w.stats.enemyDamageDealt[t] + outcome.enemyDamageTaken[slot] = w.stats.enemyDamageTaken[t] + outcome.infectionsSuffered[slot] = w.stats.infectionsSuffered[t] + outcome.infectionsInflicted[slot] = w.stats.infectionsInflicted[t] + outcome.viperInfectionDamage[slot] = w.stats.viperInfectionDamage[t] + outcome.repairs[slot] = w.stats.repairs[t] + outcome.hpRepaired[slot] = w.stats.hpRepaired[t] + outcome.rubbleClearedTenths[slot] = w.stats.rubbleClearedTenths[t] + outcome.rubbleCreatedTenths[slot] = w.stats.rubbleCreatedTenths[t] + outcome.squaresOpened[slot] = w.stats.squaresOpened[t] + outcome.basicSignals[slot] = w.stats.basicSignals[t] + outcome.messageSignals[slot] = w.stats.messageSignals[t] + outcome.archonPartsWalks[slot] = w.stats.archonPartsWalks[t] + outcome.archonsAliveAt2000[slot] = w.stats.archonsAliveAt2000[t] + outcome.archonsPerSide = w.stats.archonsStart[0] + outcome.densOnMap = w.map.dens.len + outcome.densPerSide = w.map.dens.len div 2 + var partsTotal = 0.0 + var partsSquares = 0 + for v in w.map.parts: + partsTotal += v + if v > 0.0: partsSquares += 1 + outcome.partsOnMapStart = int(partsTotal) + outcome.partsSquaresStart = partsSquares + outcome.rubbleMeanTenths = w.rubbleMeanTenths() + var impassableStart = 0 + for v in w.map.rubble: + if v >= RubbleObstructionThresh: impassableStart += 1 + outcome.impassableSquaresStart = impassableStart + outcome.impassableSquaresEnd = w.impassableSquares() + var neutrals = 0 + for b in w.map.initialRobots: + if b.team == ord(teamNeutral): neutrals += 1 + outcome.neutralsOnMapStart = neutrals + outcome.zombiesSpawned = w.stats.zombiesSpawned + outcome.zombiesKilled = w.stats.zombiesKilled + outcome.zombiesAliveEnd = w.robotCountOf(teamZombie) - w.densStanding() + outcome.outbreakLevelEnd = outbreakLevel(max(0, w.currentRound)) + outcome.scheduleRounds = w.map.schedule.len + outcome.tiebreakRound = w.maxRounds - 1 + let pts = w.gamePoints() + outcome.points[outcome.slotOf(teamA)] = pts[0] + outcome.points[outcome.slotOf(teamB)] = pts[1] + ## Rounds are 0-based, so a game that played rounds 0..2999 played 3000. + outcome.roundsPlayed = w.currentRound + 1 + outcome.hashChain = toHex(w.hashChain) + +proc playGame*( + spec: MapSpec, sheets: array[2, Sheet], chassis: array[2, ChassisKind16], + index, sideAslot, maxRounds: int, budgetSeconds: int, + onRound: proc (w: World, round: int) {.closure.} = nil +): (World, GameOutcome16) = + ## Plays one game to its end, or abandons it when `budgetSeconds` of + ## monotonic wall clock elapse. An abandoned game is DISCARDED by the match + ## (its `aborted` flag says so); it is never scored half-played. + ## + ## `budgetSeconds <= 0` means UNBOUNDED here. Note that + ## `match.nim:480` clamps `perGameBudgetSeconds` to `max(1, ...)` ONE LEVEL + ## UP, so a test helper that zeroes that field buys a ONE-SECOND budget + ## rather than an unbounded one — every bc16 test uses the + ## `if perGame > 0:` convention instead. + var w = newWorld(spec, maxRounds) + var sides = newSides16(sheets, sideAslot) + ## `sides` is indexed by TEAM and `chassis` arrives by SEAT — re-index once + ## here so the round loop never has to. + let chassisByTeam = [chassis[sideAslot], chassis[1 - sideAslot]] + var outcome = GameOutcome16( + index: index, mapName: spec.name, sideAslot: sideAslot, winnerSlot: -1) + var partsOnMap = 0.0 + for v in spec.parts: partsOnMap += v + var neutrals = 0 + for b in spec.initialRobots: + if b.team == ord(teamNeutral): neutrals += 1 + discard w.beat(BeatGameStart, "game_start", index, w.width, w.height, + spec.name & ":" & $w.stats.archonsStart[0] & ":" & $spec.dens.len & ":" & + $int(partsOnMap) & ":" & $neutrals & ":" & $spec.schedule.len) + let started = getMonoTime() + let budget = initDuration(seconds = budgetSeconds) + while w.running and w.currentRound < maxRounds - 1: + runRound(w, sides, chassisByTeam) + outcome.roundChains.add(toHex(w.hashChain)) + if onRound != nil: + onRound(w, w.currentRound) + if budgetSeconds > 0 and (w.currentRound and 0x1F) == 0 and + getMonoTime() - started >= budget: + outcome.aborted = true + break + if outcome.aborted: + outcome.endReason = "abandoned" + harvest(w, outcome) + outcome.winnerSlot = -1 + discard w.beat(BeatGameEnd, "game_abandoned", index, w.currentRound, 0, + spec.name) + return (w, outcome) + outcome.endReason = w.endReasonFor() + harvest(w, outcome) + if w.hasWinner: + outcome.winnerSlot = outcome.slotOf(w.winner) + discard w.beat(BeatGameEnd, "game_end", index, + (if outcome.winnerSlot >= 0: outcome.winnerSlot else: -1), + outcome.points[0] * 1000 + outcome.points[1], + outcome.endReason & ":" & $outcome.archonsEnd[0] & ":" & + $outcome.archonsEnd[1]) + (w, outcome) diff --git a/src/battlecode/years/bc16/signals.nim b/src/battlecode/years/bc16/signals.nim new file mode 100644 index 0000000..55c8937 --- /dev/null +++ b/src/battlecode/years/bc16/signals.nim @@ -0,0 +1,110 @@ +## The bc16 signal layer: the per-robot FIFO queue, the per-turn counters, the +## broadcast walk over ALL FOUR TEAMS and the two-counter delay charge. +## +## Ported from `world/InternalRobot.java:339-357` (the queue and the two +## counters), `world/RobotControllerImpl.java:590-634` (`readSignal`, +## `emptySignalQueue`, `broadcastSignal`, `broadcastMessageSignal`) and +## `world/GameWorld.java:798-822` (`visitBroadcastSignal`) at commit +## `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`. +## +## **THIS IS THE ONE PLACE 2016 IS HARDER THAN EVERY OTHER YEAR THIS REPO +## SHIPS: THERE IS NO SHARED ARRAY.** A robot may send 5 BASIC signals a turn +## (position + id + team, any type) and an ARCHON or a SCOUT may send 20 +## MESSAGE signals (two 32-bit ints). Each costs +## `0.05 + 0.03 * max(0, r2/sightR2 - 2)` ON BOTH COUNTERS — a flat 0.05 +## inside twice your own sight radius — and **EVERY SIGNAL IS HEARD BY THE +## ENEMY TOO**: the recipient walk keeps every robot of every team inside the +## radius except the sender. +## +## The queue is capped at `SIGNAL_QUEUE_MAX_SIZE = 1000` with THE OLDEST +## DROPPED (`signalqueue.remove(0)`), `readSignal` pops the head, and +## `emptySignalQueue` drains in order. +## +## ONE ORDERING NOTE. The engine picks its recipients through +## `getAllRobotsWithinRadiusSq`, which has three branches (`radius == 0`, +## `radius < 16` box scan, `radius >= 16` full insertion-order walk). THE SET +## IS THE SAME IN ALL THREE and each recipient receives exactly once, so only +## the order in which the queues are appended to differs — and no rule and no +## chassis can observe that, because each queue is per robot. This port uses +## the insertion-order walk for all radii and says so here rather than +## reproducing three branches with one behaviour. + +import world + +export world + +proc receiveSignal*(r: Robot, s: Signal) = + ## `InternalRobot.receiveSignal`: append, then drop the OLDEST if the queue + ## is now over 1000. + r.signalQueue.add(s) + if r.signalQueue.len > SignalQueueMaxSize: + r.signalQueue.delete(0) + +proc readSignal*(r: Robot): tuple[ok: bool, signal: Signal] = + ## `retrieveNextSignal`: pops the head, or nothing on an empty queue. + if r.signalQueue.len == 0: + return (ok: false, signal: Signal()) + let head = r.signalQueue[0] + r.signalQueue.delete(0) + (ok: true, signal: head) + +proc emptySignalQueue*(r: Robot): seq[Signal] = + ## `retrieveAllSignals`: drains in order. + result = r.signalQueue + r.signalQueue.setLen(0) + +func canBroadcast*(w: World, r: Robot, radiusSquared: int): bool = + ## `broadcastSignal`: a NEGATIVE radius is refused, and the per-turn count + ## is capped at 5. There is no readiness test — a broadcast costs delay, it + ## does not require the absence of it. + radiusSquared >= 0 and r.basicSignalCount < BasicSignalsPerTurn + +func canBroadcastMessage*(w: World, r: Robot, radiusSquared: int): bool = + ## `broadcastMessageSignal`: ARCHON and SCOUT only, non-negative radius, + ## and at most 20 a turn. + canMessageSignal(r.kind) and radiusSquared >= 0 and + r.messageSignalCount < MessageSignalsPerTurn + +proc deliver(w: World, r: Robot, radiusSquared: int, s: Signal) = + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let other = w.robotsById[id] + if other.id == r.id: continue + if other.loc.distanceSquaredTo(r.loc) <= radiusSquared: + other.receiveSignal(s) + ## `x = radius / (double) sensorRadiusSquared - 2`, then + ## `0.05 + 0.03 * max(0, x)` added to BOTH counters. A ZOMBIE's + ## `sensorRadiusSquared` is -1, which makes `x` negative and the charge the + ## flat base — and no zombie ever broadcasts anyway. + let increase = broadcastDelayIncrease(radiusSquared, + r.kind.sightRadiusSquared()) + r.d.addCoreDelay(increase) + r.d.addWeaponDelay(increase) + +proc doBroadcast*(w: World, r: Robot, radiusSquared: int): bool + {.discardable.} = + if not w.canBroadcast(r, radiusSquared): + w.refusedActions += 1 + return false + w.deliver(r, radiusSquared, + Signal(x: r.loc.x, y: r.loc.y, senderId: r.id, team: r.team, + hasMessage: false)) + r.basicSignalCount += 1 + if r.team.isPlayer(): + w.stats.basicSignals[ord(r.team)] += 1 + w.noteFirstAction(r, Bc16ActionBroadcast) + true + +proc doBroadcastMessage*(w: World, r: Robot, m1, m2, + radiusSquared: int): bool {.discardable.} = + if not w.canBroadcastMessage(r, radiusSquared): + w.refusedActions += 1 + return false + w.deliver(r, radiusSquared, + Signal(x: r.loc.x, y: r.loc.y, senderId: r.id, team: r.team, + hasMessage: true, m1: m1, m2: m2)) + r.messageSignalCount += 1 + if r.team.isPlayer(): + w.stats.messageSignals[ord(r.team)] += 1 + w.noteFirstAction(r, Bc16ActionBroadcastMessage) + true diff --git a/src/battlecode/years/bc16/units.nim b/src/battlecode/years/bc16/units.nim new file mode 100644 index 0000000..e5477ec --- /dev/null +++ b/src/battlecode/years/bc16/units.nim @@ -0,0 +1,371 @@ +## The bc16 value types and the PURE per-unit arithmetic. +## +## Everything in this file is a function of its arguments alone: the twelve-row +## `RobotType` table read straight out of `constants.nim`, the eight derived +## predicates, the outbreak ladder, the move-cost factors, the guard +## multiplier and reduction, the rubble-clear map, `directionTo`'s 2.414 fan +## and the tabled `(int) Math.sqrt(r2)`. Nothing here touches a `World`, which +## is what lets `world.nim` import it. +## +## Ported from `battlecode/battlecode-server-2016` at commit +## `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`: `common/RobotType.java`, +## `common/Direction.java`, `common/MapLocation.java`, `common/Team.java` and +## the arithmetic halves of `world/GameWorld.java` and +## `world/RobotControllerImpl.java`. +## +## FIVE PIECES OF ARITHMETIC IN HERE ARE LOAD-BEARING and are ported literally: +## +## * **2016 is a FLOAT64 year.** Health, damage, both delay counters, rubble, +## parts and every multiplier are Java `double`. IEEE-754 binary64 +## add/subtract/multiply/divide/compare are exactly specified and identical +## on x86-64 SSE2 and on wasm32, so reproducing each expression IN THE +## ENGINE'S OWN ORDER AND PARENTHESISATION is bit-exact by construction. +## Nothing here re-associates or factors: `100 * 0.95 - 10` is written the +## way `visitClearRubbleSignal` writes it, `2 * 1.4 * 2` the way `move()` +## does. +## * **`directionTo` uses the engine's own doubles**, not an integer +## rescaling: `Math.abs(dx) >= 2.414 * Math.abs(dy)` with `2.414` as the +## double the literal denotes. An integer form (`ax*1000 >= ay*2414`) is a +## DIFFERENT predicate on some lattice points, and this direction decides +## every zombie's step and every den's spawn ring. +## * **`(int) Math.sqrt(r2)` is TABLED for r2 = 0..10 000**, so there is no +## `sqrt` on any runtime path. `Math.sqrt` is exactly rounded, so the +## integer-search table below is the same value for every argument in range. +## * **The outbreak multiplier applies to a ZOMBIE's `maxHealth` and +## `attackPower` AT THE MOMENT IT SPAWNS** (`InternalRobot.java:68,70`) and +## never afterwards; a player unit never scales +## (`RobotType.java:352-358`). +## * **The GUARD pair**: a GUARD ATTACKER doubles its damage against a zombie +## TARGET (`rate = 2.0`), and a GUARD TARGET hit for MORE THAN 10.0 takes +## `damage - 4.0`. The threshold is strict `>`; 10 exactly is unreduced. + +import constants + +export constants + +type + Team* = enum + ## `common/Team.java` in `values()` order, which is the index space the + ## converted map file's `team` column lives in. FOUR values: the horde and + ## the neutrals are real teams that own real robots. + teamA = 0 + teamB = 1 + teamNeutral = 2 + teamZombie = 3 + + Dir* = enum + ## `common/Direction.java` in `values()` order. NOTE THE Y AXIS: 2016's + ## NORTH is `(0, -1)` — y grows SOUTHWARD — which is the opposite of + ## bc22's port and is why this year has its own `Dir`. + ## `ZombieControlProvider.DIRECTIONS` is exactly the first eight, in this + ## order, and `random.nextInt(8)` indexes it. + dNorth = 0 + dNortheast + dEast + dSoutheast + dSouth + dSouthwest + dWest + dNorthwest + dNone + dOmni + + Symmetry* = enum + ## `world/GameMap.Symmetry` in `values()` order, which is ALSO the + ## first-wins test order of `updateSymmetries` (D4). Only the winner is + ## read at run time, and only by `getSpawnChirality`. + symVertical = "vertical" + symHorizontal = "horizontal" + symRotational = "rotational" + symNegativeDiagonal = "negative_diagonal" + symPositiveDiagonal = "positive_diagonal" + symNone = "none" + + Loc* = object + x*, y*: int + + Domination* = enum + ## `world/DominationFactor.java` in this repo's snake_case `end_reason` + ## vocabulary. `ZOMBIFIED` and `CLEANSED` have no values: both are + ## reachable only on armageddon maps, which are out of scope (V4). + ## `RESIGNATION` has none either: `rc.resign()` is a real engine method + ## but a doctrine is a JSON sheet and neither chassis can call it (V5). + dfNone + dfDestroyed = "archons_destroyed" + dfPwned = "more_archons" + dfOwned = "more_archon_health" + dfBarelyBeat = "more_parts_net_worth" + dfDubious = "highest_id" + + DeathCause* = enum + ## `world/signal/DeathSignal.RobotDeathCause`. `TURRET` cuts the corpse's + ## rubble to a third; `ACTIVATION` skips BOTH the rubble deposit and the + ## infection conversion. + dcNormal + dcTurret + dcActivation + +const + MoveDirs* = [dNorth, dNortheast, dEast, dSoutheast, + dSouth, dSouthwest, dWest, dNorthwest] + ## `ZombieControlProvider.DIRECTIONS`, and the eight `move`/`build` + ## directions. The ORDER IS A RULE: `random.nextInt(8)` indexes it and + ## `DIRECTIONS[floorMod(start + i * chir, 8)]` walks it. + + ZombieSpawnTypes* = [rtStandardzombie, rtRangedzombie, rtFastzombie, + rtBigzombie] + ## `ZombieControlProvider.ZOMBIE_TYPES`. `spawnAllPossible`'s type loop has + ## NO `break`, so it keeps the LAST non-zero entry — i.e. the spawn + ## priority is BIGZOMBIE, then FASTZOMBIE, then RANGEDZOMBIE, then + ## STANDARDZOMBIE. + + PlayerTypes* = [rtArchon, rtScout, rtSoldier, rtGuard, rtViper, rtTurret, + rtTtm] + + BuildableByArchon* = [rtScout, rtSoldier, rtGuard, rtViper, rtTurret] + ## `isBuildable() and spawnSource == ARCHON`. TTM is NOT here: its + ## `spawnSource` is TURRET, so a TTM is not buildable at all and is only + ## reachable by packing. + + IntSqrtMax* = 10_000 + IntSqrtTable*: array[IntSqrtMax + 1, int] = block: + ## `(int) Math.sqrt(r2)` for every squared radius the 2016 rule set can + ## reach, so there is no `sqrt` and therefore no `fdlibm` path anywhere on + ## a runtime path. `Math.sqrt` is exactly rounded for a `double` argument, + ## so truncating it equals the integer floor computed here. + var t: array[IntSqrtMax + 1, int] + var k = 0 + for r2 in 0 .. IntSqrtMax: + while (k + 1) * (k + 1) <= r2: inc k + t[r2] = k + t + +func isPlayer*(t: Team): bool = t == teamA or t == teamB + ## `Team.isPlayer()`. NEUTRAL and ZOMBIE are not players, which is what + ## `getNearestPlayerControlled` filters on. + +func opponent*(t: Team): Team = + ## `Team.opponent()`: A <-> B, and NEUTRAL/ZOMBIE map to themselves. + case t + of teamA: teamB + of teamB: teamA + else: t + +func other*(t: Team): Team = t.opponent() + +func dx*(d: Dir): int = + case d + of dNorth, dSouth, dNone, dOmni: 0 + of dNortheast, dEast, dSoutheast: 1 + of dSouthwest, dWest, dNorthwest: -1 + +func dy*(d: Dir): int = + ## 2016's y axis points SOUTH: NORTH is `(0, -1)`. + case d + of dEast, dWest, dNone, dOmni: 0 + of dNorth, dNortheast, dNorthwest: -1 + of dSoutheast, dSouth, dSouthwest: 1 + +func isDiagonal*(d: Dir): bool = + ## `Direction.isDiagonal()`: `ordinal() < 8 and ordinal() % 2 == 1`. + ord(d) < 8 and (ord(d) mod 2) == 1 + +func opposite*(d: Dir): Dir = + if ord(d) >= 8: d else: Dir((ord(d) + 4) mod 8) + +func rotateLeft*(d: Dir): Dir = + if ord(d) >= 8: d elif ord(d) == 0: dNorthwest else: Dir(ord(d) - 1) + +func rotateRight*(d: Dir): Dir = + if ord(d) >= 8: d elif ord(d) == 7: dNorth else: Dir(ord(d) + 1) + +func loc*(x, y: int): Loc = Loc(x: x, y: y) +func `+`*(a: Loc, d: Dir): Loc = loc(a.x + d.dx, a.y + d.dy) +func `==`*(a, b: Loc): bool = a.x == b.x and a.y == b.y +func translate*(a: Loc, ddx, ddy: int): Loc = loc(a.x + ddx, a.y + ddy) + +func distanceSquaredTo*(a, b: Loc): int = + let ddx = a.x - b.x + let ddy = a.y - b.y + ddx * ddx + ddy * ddy + +func isAdjacentTo*(a, b: Loc): bool = + let d = a.distanceSquaredTo(b) + d == 1 or d == 2 + +func chebyshev*(a, b: Loc): int = max(abs(a.x - b.x), abs(a.y - b.y)) + +func compareLoc*(a, b: Loc): int = + ## `MapLocation.compareTo`: x first, then y. `getSpawnChirality` reads only + ## its SIGN, and the expression is translation invariant, which is why the + ## origin can be dropped (V3). + if a.x != b.x: a.x - b.x else: a.y - b.y + +func directionTo*(a, b: Loc): Dir = + ## `MapLocation.directionTo` (`:146-183`), with the engine's own doubles. + ## Equal locations give OMNI. THIS IS THE WHOLE ZOMBIE TARGETING GEOMETRY + ## and the den spawn ring's start, so the comparison is written exactly as + ## the engine writes it rather than rescaled into integers. + let ddx = float64(b.x - a.x) + let ddy = float64(b.y - a.y) + if abs(ddx) >= 2.414 * abs(ddy): + if ddx > 0.0: return dEast + elif ddx < 0.0: return dWest + else: return dOmni + elif abs(ddy) >= 2.414 * abs(ddx): + return (if ddy > 0.0: dSouth else: dNorth) + else: + if ddy > 0.0: + return (if ddx > 0.0: dSoutheast else: dSouthwest) + else: + return (if ddx > 0.0: dNortheast else: dNorthwest) + +func intSqrt*(r2: int): int = + ## The tabled `(int) Math.sqrt(radiusSquared)`, clamped to the table. + if r2 <= 0: 0 + elif r2 >= IntSqrtMax: IntSqrtTable[IntSqrtMax] + else: IntSqrtTable[r2] + +# --------------------------------------------------------------------------- +# The per-type table and its eight derived predicates +# --------------------------------------------------------------------------- + +func spec*(k: RobotType): RobotSpec = RobotSpecs[k] + +func typeOfOrdinal*(o: int): RobotType = + ## The converted map file's `type` column and the spec table's + ## `spawnSource` / `turnsInto` are ORDINALS. + RobotType(o) + +func canAttack*(k: RobotType): bool = RobotSpecs[k].attackPower > 0.0 + ## `RobotType.canAttack()`. ARCHON, SCOUT, TTM and ZOMBIEDEN cannot attack. + +func canInfect*(k: RobotType): bool = RobotSpecs[k].infectTurns > 0 + ## VIPER and all four zombies. + +func isZombieType*(k: RobotType): bool = RobotSpecs[k].isZombie + +func isInfectable*(k: RobotType): bool = + ## `!isZombie && this != ZOMBIEDEN` — EVERY player unit, archons included. + (not RobotSpecs[k].isZombie) and k != rtZombieden + +func canMoveType*(k: RobotType): bool = k != rtZombieden and k != rtTurret + +func canBuildType*(k: RobotType): bool = k == rtArchon or k == rtZombieden + +func canMessageSignal*(k: RobotType): bool = k == rtArchon or k == rtScout + +func isBuildable*(k: RobotType): bool = + ## `spawnSource == ARCHON || spawnSource == ZOMBIEDEN`. TTM's spawnSource is + ## TURRET, so a TTM is NOT buildable and can only be reached by packing. + RobotSpecs[k].spawnSource == ord(rtArchon) or + RobotSpecs[k].spawnSource == ord(rtZombieden) + +func canClearRubble*(k: RobotType): bool = k != rtTurret and k != rtTtm + +func ignoresRubble*(k: RobotType): bool = RobotSpecs[k].ignoresRubble + +func turnsInto*(k: RobotType): RobotType = + ## `RobotType.turnsInto`. ARCHON -> BIGZOMBIE, SCOUT -> FASTZOMBIE, + ## SOLDIER and GUARD -> STANDARDZOMBIE, VIPER/TURRET/TTM -> RANGEDZOMBIE. + ## Only ever read for an infectable type, all of which have one. + RobotType(RobotSpecs[k].turnsInto) + +func hasTurnsInto*(k: RobotType): bool = RobotSpecs[k].turnsInto >= 0 + +func partCost*(k: RobotType): int = RobotSpecs[k].partCost +func buildTurns*(k: RobotType): int = RobotSpecs[k].buildTurns +func sightRadiusSquared*(k: RobotType): int = RobotSpecs[k].sensorRadiusSquared +func attackRadiusSquared*(k: RobotType): int = + RobotSpecs[k].attackRadiusSquared + +func budgetFor*(k: RobotType): int = + ## The `DecisionOps` budget that replaces `RobotType.bytecodeLimit` (V2): + ## one tenth of the engine's own limit. A ZOMBIEDEN and a zombie ARE THE SIM + ## and have no budget at all — `zombies.nim` never charges one. + if k == rtArchon or k == rtScout: DecisionOpsWide + else: DecisionOpsStandard + +# --------------------------------------------------------------------------- +# The outbreak ladder +# --------------------------------------------------------------------------- + +func outbreakLevel*(round: int): int = + ## `round / GameConstants.OUTBREAK_TIMER`, integer division. Rounds are + ## 0-based, so level 9 is the last one a 3000-round game reaches. + if round <= 0: 0 else: round div OutbreakTimer + +func outbreakMultiplier*(round: int): float64 = + ## `RobotType.getOutbreakMultiplier(round)`, from the tabled switch. Above + ## the table the engine's own `3.00 + (level - 9)` arm is evaluated. + let level = outbreakLevel(round) + if level < OutbreakMultipliers.len: OutbreakMultipliers[level] + else: 3.00 + float64(level - 9) + +func maxHealthOf*(k: RobotType, round: int): float64 = + ## `RobotType.maxHealth(round)`: scaled for a ZOMBIE, base for a player + ## unit, evaluated at the round the robot SPAWNS. + if RobotSpecs[k].isZombie: + RobotSpecs[k].maxHealth * outbreakMultiplier(round) + else: + RobotSpecs[k].maxHealth + +func attackPowerOf*(k: RobotType, round: int): float64 = + if RobotSpecs[k].isZombie: + RobotSpecs[k].attackPower * outbreakMultiplier(round) + else: + RobotSpecs[k].attackPower + +# --------------------------------------------------------------------------- +# Rubble, movement and damage arithmetic +# --------------------------------------------------------------------------- + +func rubbleAfterClear*(rubble: float64): float64 = + ## `visitClearRubbleSignal` + `alterRubble`'s `max(0.0, ...)`: + ## `max(0, r * (1 - 0.05) - 10.0)`. Vectors: 100 -> 85, 10 -> 0, + ## 1 000 000 -> 949 990. + max(0.0, (rubble * (1.0 - RubbleClearPercentage)) - RubbleClearFlatAmount) + +func rubbleBlocks*(rubble: float64, k: RobotType): bool = + ## `GameWorld.canMove`'s rubble half: `rubble < 100.0 || ignoresRubble`. + ## A SCOUT, a FASTZOMBIE and a BIGZOMBIE pass anything. + not (rubble < RubbleObstructionThresh or RobotSpecs[k].ignoresRubble) + +func rubbleSlows*(rubble: float64, k: RobotType): bool = + ## `move()`'s `factor3`: `!ignoresRubble && rubble(dest) >= 50.0`. + (not RobotSpecs[k].ignoresRubble) and rubble >= RubbleSlowThresh + +func moveFactor1*(d: Dir): float64 = + ## `move()`'s `factor1`, the DIAGONAL multiplier — and it hits the CORE + ## delay only. + if d.isDiagonal(): DiagonalDelayMultiplier else: 1.0 + +func moveFactor3*(rubble: float64, k: RobotType): float64 = + if rubbleSlows(rubble, k): 2.0 else: 1.0 + +func guardRate*(attacker, target: RobotType): float64 = + ## `visitAttackSignal`: a GUARD attacker doubles against a ZOMBIE target. + if attacker == rtGuard and RobotSpecs[target].isZombie: + GuardZombieMultiplier + else: + 1.0 + +func damageToTarget*(rawDamage: float64, target: RobotType): float64 = + ## `visitAttackSignal`'s guard block: a GUARD target hit for MORE THAN 10.0 + ## takes `damage - 4.0`. Strictly greater — 10.0 exactly is unreduced. + if target == rtGuard and rawDamage > GuardDefenseThreshold: + rawDamage - GuardDamageReduction + else: + rawDamage + +func rubbleFactorFor*(cause: DeathCause): float64 = + ## `visitDeathSignal`: `1.0` normally, `1.0/3.0` when a TURRET landed the + ## killing blow. `145 * (1.0/3.0) = 48.333333333333336` is a named vector. + if cause == dcTurret: RubbleFromTurretFactor else: 1.0 + +func broadcastDelayIncrease*(radiusSquared: int, sightR2: int): float64 = + ## `visitBroadcastSignal`: `x = r2 / (double) sensorRadiusSquared - 2`, then + ## `0.05 + 0.03 * max(0, x)`, ADDED TO BOTH counters. A broadcast inside + ## twice your own sight radius costs a flat 0.05. + let x = (float64(radiusSquared) / float64(sightR2)) - 2.0 + BroadcastBaseDelayIncrease + BroadcastAdditionalDelayIncrease * max(0.0, x) diff --git a/src/battlecode/years/bc16/world.nim b/src/battlecode/years/bc16/world.nim new file mode 100644 index 0000000..c9d425b --- /dev/null +++ b/src/battlecode/years/bc16/world.nim @@ -0,0 +1,1098 @@ +## The Battlecode 2016 "Zombie Invasion" world: state, geometry and every +## legality rule a ROBOT can reach. +## +## A behaviour-for-behaviour port of `world/GameWorld.java` (1051 lines), +## `world/InternalRobot.java` (473) and `world/RobotControllerImpl.java` (886) +## at commit `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`, together with the +## pieces of `common/MapLocation.java` and `common/Direction.java` the rules +## depend on. The port is the authority at run time; the Java engine survives +## only as the `parity-oracle-bc16` CI job (docs/PARITY.md). +## +## `units.nim`, `delays.nim` and `health.nim` are pure and are imported BY this +## file; `zombies.nim`, `economy.nim` and `signals.nim` import it. +## +## **NINE THINGS IN HERE LOOK LIKE DETAILS AND ARE NOT:** +## +## * **The exec order is INSERTION order** and it is the only robot collection +## the engine ever iterates. `gameObjectsByID` is a `LinkedHashMap` +## (`GameWorld.java:72`), so `runRound`'s snapshot (`:158`), `allObjects()` +## (`:239`) — which `senseNearbyRobots` and `senseHostileRobots` read — +## `getAllGameObjects()` (`:243`) — which rung 3 of the end ladder reads — +## `getAllRobotsWithinRadiusSq` for `r2 >= 16` (`:400`) and +## `getNearestPlayerControlled` (`:421`) are all the SAME order: the map +## file's initial robots in FILE order, then every spawn in spawn order, with +## removal on death leaving the survivors' relative order intact. THERE IS +## NO TROVE, NO `net.sf.jsi`, NO `EnumMap` ITERATION AND NO HASH-ORDERED +## ROBOT SWEEP ANYWHERE IN THE 2016 ROUND LOOP (D2), so this port needs no +## hash-map port of any kind at run time and keeps one `seq[int]` with +## append-on-spawn and BY-VALUE removal on death, plus a `Table[int, Robot]` +## for lookup that is NEVER ITERATED. +## * **`currentRound` starts at -1** (`:69`), so the first round played is +## round 0 and the last is 2999. Every round number in the trace, the replay +## and the viewer clock is 0-based, exactly as the engine's is. +## * **Rounds are 0-based AND the outbreak scaling is fixed at spawn.** A +## robot's `maxHealth` and `attackPower` are computed once, in the +## constructor, from `currentRound` (`InternalRobot.java:68-70`). +## * **`isActive()` is `!type.isBuildable() || roundsAlive >= buildDelay`** +## (`:179`) and `getBytecodeLimit()` returns 0 when a robot cannot execute +## code (`:197`), so a SOLDIER built this round is a live, targetable, +## blocking, damageable robot that DOES NOTHING for 12 turns. Here it gets a +## `DecisionOps` budget of 0 and its controller is not run. +## * **An attack needs no vision, no target and no team check.** +## `attackLocation` tests the radius (and `d2 >= 6` for a TURRET) and +## nothing else; the signal collects `getAllRobotsWithinRadiusSq(loc, 0)` — +## SPLASH RADIUS ZERO, so exactly the one robot on that square or none. AN +## ATTACK ON AN EMPTY SQUARE IS LEGAL AND COSTS FULL DELAY, AND SO IS +## FRIENDLY FIRE. +## * **`changeHealthLevel` is the single mutation point for health** and it is +## re-entrant: an attack, a repair, the viper infection tick and the den's +## proximity damage all come through it, and it calls the death path at +## `<= 0`. +## * **`DESTROYED` fires MID-TURN** inside the death path and `running` stays +## true until the end of the round, so every robot after the killer in the +## exec order still takes its turn. The second `setWinner` is GUARDED by +## `winner == null` (`:882`), so if both factions lose their last archon in +## the same round THE FIRST ONE TO LOSE IT LOSES. +## * **An archon takes every part on any square it spawns on or moves onto**, +## all of it, and nothing else in the game collects parts. +## * **A ZOMBIEDEN skips the pathability test when it builds** (`:702-704`) and +## its `canBuild` is only `isEmpty(loc)` (`:662-664`), so a den spawns onto +## rubble no player unit could stand on. + +import std/tables +import ../../sim_types, ../../rng +import units, delays, health + +export units, delays, health, rng, tables + +type + DenSpec* = object + ## One den's pre-split share of the public schedule, computed at BUILD + ## time by `tools/convert_maps_bc16.py` (D3) together with the two + ## memoised constants `ZombieControlProvider` caches per location. + x*, y*: int + spawnDir*: int + chirality*: int + schedule*: seq[tuple[round: int, counts: array[4, int]]] + + MapSpec* = object + ## One converted 2016 `.xml`, as `data/maps/bc16/.json` carries it. + ## Coordinates are ORIGIN-RELATIVE (V3). + name*: string + width*, height*: int + randomSeed*: int + rounds*: int + symmetry*: Symmetry + symmetriesFound*: seq[Symmetry] + rubble*: seq[float64] ## `y * width + x`, as `SquareArray.Double` is + parts*: seq[float64] + initialRobots*: seq[tuple[x, y, kind, team: int]] + ## IN FILE ORDER, because that order IS the opening exec order. + schedule*: seq[tuple[round: int, counts: array[4, int]]] + ## The WHOLE-MAP schedule, which `getZombieSpawnSchedule()` exposes free + ## to every robot of both factions. + dens*: seq[DenSpec] + + Signal* = object + ## `common/Signal.java`: a basic signal carries the sender's location, id + ## and team; a message signal adds two 32-bit ints. + x*, y*, senderId*: int + team*: Team + hasMessage*: bool + m1*, m2*: int + + Robot* = ref object + id*: int + team*: Team + kind*: RobotType + loc*: Loc + health*: float64 + maxHealth*: float64 + ## Fixed at spawn from `type.maxHealth(currentRound)` — outbreak-scaled + ## for a zombie and never recomputed. + attackPower*: float64 + d*: Delays + inf*: Infection + roundsAlive*: int + buildDelay*: int + repairCount*: int + basicSignalCount*: int + messageSignalCount*: int + signalQueue*: seq[Signal] + denQueue*: array[4, int] + ## `ZombieControlProvider.denQueues[id]`, by `ZombieSpawnTypes` index. + denIndex*: int ## index into `map.dens`, or -1 + alive*: bool + disintegrated*: bool + opsLeft*: int + opsUsed*: int + ## --- chassis-side memory, never read by a rule --- + greenhornRng*: JavaRandom + ## `new java.util.Random(2016)` PER ROBOT: static fields are per robot + ## under the instrumenter, so `greenhorn` needs no determinism patch. + noRepeat*: seq[Loc] + task*: int + taskLoc*: Loc + hasTask*: bool + quarantineUntil*: int + + TeamStats* = object + ## The per-game counters `results.games[]` reports, plus the two the + ## engine really keeps (the stockpiles live in `World.resources`). + ## Everything here is telemetry and is NEVER READ BY A RULE. + archonsStart*: array[2, int] + archonsLost*: array[2, int] + archonHealthEndTenths*: array[2, int] + partsCollectedTenths*: array[2, int] + partsIncomeTenths*: array[2, int] + partsSpentTenths*: array[2, int] + unitsBuilt*: array[2, int] + scoutsBuilt*: array[2, int] + soldiersBuilt*: array[2, int] + guardsBuilt*: array[2, int] + vipersBuilt*: array[2, int] + turretsBuilt*: array[2, int] + turretPacks*: array[2, int] + robotsLost*: array[2, int] + robotsTurned*: array[2, int] + neutralsActivated*: array[2, int] + neutralArchonsActivated*: array[2, int] + densDestroyed*: array[2, int] + denDamageDealt*: array[2, int] + damageDealt*: array[2, int] + zombieDamageDealt*: array[2, int] + zombieDamageTaken*: array[2, int] + enemyDamageDealt*: array[2, int] + enemyDamageTaken*: array[2, int] + infectionsSuffered*: array[2, int] + infectionsInflicted*: array[2, int] + viperInfectionDamage*: array[2, int] + repairs*: array[2, int] + hpRepaired*: array[2, int] + rubbleClearedTenths*: array[2, int] + rubbleCreatedTenths*: array[2, int] + squaresOpened*: array[2, int] + basicSignals*: array[2, int] + messageSignals*: array[2, int] + archonPartsWalks*: array[2, int] + archonsAliveAt2000*: array[2, int] + ## --- globals --- + zombiesSpawned*: int + zombiesKilled*: int + + World* = ref object + map*: MapSpec + width*, height*: int + currentRound*: int + maxRounds*: int + running*: bool + idGen*: IdGenerator + ## D2a — `IDGenerator(mapSeed)`, whose 4096-id blocks START AT ID 1 in + ## 2016 (`nextIDBlock = 0`, `reservedIDs[i] = nextIDBlock + i + 1`), + ## unlike every later year's 10 000 floor. It fixes the id of EVERY + ## robot including the initial ones. + rand*: JavaRandom + ## D2b — `GameWorld.rand = new Random(mapSeed)` (`:134`), read at + ## EXACTLY ONE SITE: `getNearestPlayerControlled`'s + ## `rand.nextInt(closest.size())`, ONCE PER ZOMBIE TURN in which any + ## player robot is alive, INCLUDING when there is exactly one candidate + ## (`nextInt(1)` still consumes a `next(31)`). The highest-traffic RNG + ## stream in any year this repo ships. + zombieRand*: JavaRandom + ## D2c — `ZombieControlProvider.random = new Random(mapSeed)` (`:84`), + ## read at exactly two sites in `processZombie`. + symmetry*: Symmetry + rubble*: seq[float64] + partsAt*: seq[float64] + occupant*: seq[Robot] + robotsById*: Table[int, Robot] + execOrder*: seq[int] + ## INSERTION ORDER (D2). Never sorted, never re-ordered. + typeCount*: array[4, array[RobotType, int]] + robotCount*: array[4, int] + resources*: array[4, float64] + winner*: Team + hasWinner*: bool + domination*: Domination + tiebreakRung*: int + stats*: TeamStats + ## Replay/telemetry sinks — never read by a rule. + events*: seq[tuple[round: int, kind: string, a, b, c: int, s: string]] + hashChain*: uint64 + beatCount*: array[24, int] + refusedActions*: int + ## THE LEGALITY AUDIT. Every `do*` re-checks its own `can*` and no-ops + ## when it fails; this counts those no-ops. + ## `tests/test_bc16_baselines.nim` plays whole games and asserts it + ## stays ZERO for both chassis. + opsUsedPeak*: int + firstActionSeen*: array[2, bool] + unitMilestoneSeen*: array[2, array[RobotType, bool]] + lostThisRound*: array[2, int] + attackersLostThisRound*: array[2, int] + turnedThisRound*: seq[tuple[team: int, kind, became: RobotType, l: Loc]] + archonLostThisRound*: seq[tuple[team: int, cause: string]] + lastDamageSource*: array[4, string] + brokenChassis*: bool + ## Set by `-d:bc16BrokenChassis`: the NEGATIVE CONTROL for the + ## economic-survival gate. Archons never build a GUARD and never repair, + ## units ignore `infection_policy` entirely, and the faction never + ## commits to a den. + +# --------------------------------------------------------------------------- +# Beats and events +# --------------------------------------------------------------------------- + +const + BeatEpisode* = 0 + BeatGameStart* = 1 + BeatFirstAction* = 2 + BeatUnitMilestone* = 3 + BeatZombieWave* = 4 + BeatOutbreak* = 5 + BeatDenDestroyed* = 6 + BeatNeutralActivated* = 7 + BeatInfection* = 8 + BeatTurned* = 9 + BeatArchonLost* = 10 + BeatRout* = 11 + BeatDuel* = 12 + BeatTiebreak* = 13 + BeatGameEnd* = 14 + + BeatBounds* = [1, 1, 2, 10, 30, 10, 12, 20, 20, 24, 8, 20, 20, 1, 2, + 0, 0, 0, 0, 0, 0, 0, 0, 0] + ## Per GAME, in the design note's own event table. + ## `tests/test_bc16_replay.nim` asserts every one of them against a real + ## match, so a pathological game cannot produce a 20 MB replay. + ## `zombie_wave` is 30 against a MEASURED maximum schedule length of 29 + ## (`wormy`). + + Bc16ActionClearRubble* = 0 + Bc16ActionMove* = 1 + Bc16ActionAttack* = 2 + Bc16ActionBroadcast* = 3 + Bc16ActionBroadcastMessage* = 4 + Bc16ActionBuild* = 5 + Bc16ActionActivate* = 6 + Bc16ActionRepair* = 7 + Bc16ActionPack* = 8 + Bc16ActionUnpack* = 9 + Bc16ActionDisintegrate* = 10 + ## The ordinals `years/dispatch.nim`'s `Bc16ActionNames` spells out, so + ## `first_action.action` has a DOCUMENTED VOCABULARY (the bc23 r1-F14 + ## lesson). The field is `action`, never `kind`: a field named `kind` is + ## flattened into the same object as the event's own `kind` key and + ## silently overwrites it (the bc23 r1-F25 finding). + +proc emit*(w: World, kind: string, a = 0, b = 0, c = 0, s = "") = + w.events.add((round: w.currentRound, kind: kind, a: a, b: b, c: c, s: s)) + +proc beat*(w: World, slot: int, kind: string, a = 0, b = 0, c = 0, + s = ""): bool {.discardable.} = + if w.beatCount[slot] >= BeatBounds[slot]: return false + w.beatCount[slot] += 1 + w.emit(kind, a, b, c, s) + true + +proc mixHash*(w: World, v: int) = + w.hashChain = (w.hashChain xor uint64(v and 0xFFFFFFFF)) * + 0x100000001B3'u64 + +proc mixHashU*(w: World, v: uint64) = + ## The same step for a value that is ALREADY 64 bits. `int` is 32 bits under + ## wasm32, so masking into an `int` first raises `RangeDefect` in the browser + ## while the native suite stays green (the bc22 lesson). + w.hashChain = (w.hashChain xor (v and 0xFFFFFFFF'u64)) * + 0x100000001B3'u64 + +func fnv1a64*(values: openArray[int]): uint64 = + result = 0xcbf29ce484222325'u64 + for v in values: + result = (result xor uint64(v and 0xFFFFFFFF)) * 0x100000001B3'u64 + +# --------------------------------------------------------------------------- +# Geometry +# --------------------------------------------------------------------------- + +func idx*(w: World, l: Loc): int = l.x + l.y * w.width +func indexToLoc*(w: World, i: int): Loc = loc(i mod w.width, i div w.width) + +func onTheMap*(w: World, l: Loc): bool = + ## `GameMap.onTheMap`, with the origin subtracted (V3). + l.x >= 0 and l.y >= 0 and l.x < w.width and l.y < w.height + +iterator locationsWithinRadiusSquared*(w: World, center: Loc, + r2: int): Loc = + ## `MapLocation.getAllMapLocationsWithinRadiusSq` / + ## `GameWorld.getAllMapLocationsWithinRadiusSq`, verbatim: the box is + ## `(int) Math.sqrt(r2)` wide, X ASCENDING OUTER and Y ASCENDING INNER, and + ## a square passes when `d2 <= r2`. That order fixes which parts square a + ## chassis sees first. + let radius = intSqrt(r2) + for x in center.x - radius .. center.x + radius: + for y in center.y - radius .. center.y + radius: + let l = loc(x, y) + if w.onTheMap(l) and l.distanceSquaredTo(center) <= r2: + yield l + +func symmetricLoc*(w: World, l: Loc): Loc = + ## `Symmetry.getOpposite` at zero origin. + case w.symmetry + of symVertical: loc(l.x, w.height - 1 - l.y) + of symHorizontal: loc(w.width - 1 - l.x, l.y) + of symRotational: loc(w.width - 1 - l.x, w.height - 1 - l.y) + of symNegativeDiagonal: loc(w.height - 1 - l.y, w.width - 1 - l.x) + of symPositiveDiagonal: loc(l.y, l.x) + of symNone: l + +# --------------------------------------------------------------------------- +# Terrain, occupancy and lookup +# --------------------------------------------------------------------------- + +func getRubble*(w: World, l: Loc): float64 = + ## `GameWorld.getRubble` returns 0 off the map. + if w.onTheMap(l): w.rubble[w.idx(l)] else: 0.0 + +proc alterRubble*(w: World, l: Loc, amount: float64) = + ## `GameWorld.alterRubble`: `max(0.0, amount)`, and the caller passes the + ## whole new value. + if w.onTheMap(l): + w.rubble[w.idx(l)] = max(0.0, amount) + +func getParts*(w: World, l: Loc): float64 = + if w.onTheMap(l): w.partsAt[w.idx(l)] else: 0.0 + +func getRobot*(w: World, l: Loc): Robot = + if w.onTheMap(l): w.occupant[w.idx(l)] else: nil + +func isLocationOccupied*(w: World, l: Loc): bool = w.getRobot(l) != nil + +func isEmpty*(w: World, l: Loc): bool = + ## `GameWorld.isEmpty`: on the map AND unoccupied. Rubble is not consulted — + ## which is exactly what lets a den spawn onto a wall. + w.onTheMap(l) and w.getRobot(l) == nil + +func robotById*(w: World, id: int): Robot = + if w.robotsById.hasKey(id): w.robotsById[id] else: nil + +func existsRobot*(w: World, id: int): bool = w.robotsById.hasKey(id) + +func robotTypeCount*(w: World, t: Team, k: RobotType): int = + w.typeCount[ord(t)][k] + +func robotCountOf*(w: World, t: Team): int = w.robotCount[ord(t)] + +func archonsAlive*(w: World, t: Team): int = w.typeCount[ord(t)][rtArchon] + +func teamParts*(w: World, t: Team): float64 = w.resources[ord(t)] + +proc adjustResources*(w: World, t: Team, amount: float64) = + ## `GameWorld.adjustResources`. The engine clamps NOTHING; a spend that + ## would take a stockpile negative is a legality bug in the caller, so it + ## raises here and the server turns it into `results.reason = fault`. + if w.resources[ord(t)] + amount < 0.0: + raise newException(BattlecodeError, "bc16: invalid parts change") + w.resources[ord(t)] += amount + +func isActive*(r: Robot): bool = + ## `InternalRobot.isActive`. + (not isBuildable(r.kind)) or r.roundsAlive >= r.buildDelay + +func canExecuteCode*(r: Robot): bool = r.health > 0.0 and r.isActive() + +func canSense*(w: World, r: Robot, l: Loc): bool = + ## `InternalRobot.canSense`: `sensorRadiusSquared == -1` means the WHOLE + ## MAP — every zombie and every den, always. + let s = r.kind.sightRadiusSquared() + if s == -1: true + else: r.loc.distanceSquaredTo(l) <= s + +func canAttackSquare*(w: World, r: Robot, l: Loc): bool = + ## `GameWorld.canAttackSquare`: inside the attack radius, and for a TURRET + ## ALSO at or beyond `TURRET_MINIMUM_RANGE = 6`. NO on-the-map test and NO + ## vision test. + let d = r.loc.distanceSquaredTo(l) + let radius = r.kind.attackRadiusSquared() + if r.kind == rtTurret: + d <= radius and d >= TurretMinimumRange + else: + d <= radius + +func canMoveTo*(w: World, l: Loc, k: RobotType): bool = + ## `GameWorld.canMove(loc, type)`: on the map, rubble under 100 unless the + ## type ignores rubble, and UNOCCUPIED. + w.onTheMap(l) and (not rubbleBlocks(w.getRubble(l), k)) and + w.getRobot(l) == nil + +# --------------------------------------------------------------------------- +# DecisionOps — the budget that replaces the JVM bytecode limit (V2) +# --------------------------------------------------------------------------- + +proc spend*(r: Robot, n: int): bool {.discardable.} = + ## Charged BEFORE each primitive and never inside one, so a primitive's + ## RESULT is never a function of the remaining budget — only whether the + ## chassis got to ask. When the budget runs out the robot's turn ends where + ## it stands; it is not resumed mid-computation next turn, which is the one + ## place this differs from the JVM. NO RULE READS IT (V1). + if r.opsLeft < n: return false + r.opsLeft -= n + r.opsUsed += n + true + +# --------------------------------------------------------------------------- +# Sensing — every one of these walks the INSERTION-ORDERED exec list +# --------------------------------------------------------------------------- + +iterator allRobots*(w: World): Robot = + ## `allObjects()` / `getAllGameObjects()`: `gameObjectsByID.values()` in + ## LinkedHashMap insertion order. + for id in w.execOrder: + if w.robotsById.hasKey(id): + yield w.robotsById[id] + +iterator senseNearbyRobots*(w: World, r: Robot, r2: int): Robot = + ## `senseNearbyRobots(center, radiusSquared, team)` with a null team: walk + ## `allObjects()`, keep what this robot CAN SENSE, drop itself, apply the + ## radius when `radiusSquared >= 0`. RETURNS IN INSERTION ORDER, which is + ## what fixes which enemy a chassis sees first. + let useRadius = r2 >= 0 + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let o = w.robotsById[id] + if o.id == r.id: continue + if not w.canSense(r, o.loc): continue + if useRadius and o.loc.distanceSquaredTo(r.loc) > r2: continue + yield o + +iterator senseHostileRobots*(w: World, r: Robot, r2: int): Robot = + ## `senseHostileRobots`: the same walk, keeping the ENEMY TEAM **and** + ## `Team.ZOMBIE`. + let useRadius = r2 >= 0 + let enemy = r.team.opponent() + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let o = w.robotsById[id] + if o.id == r.id: continue + if not w.canSense(r, o.loc): continue + if useRadius and o.loc.distanceSquaredTo(r.loc) > r2: continue + if o.team == enemy or o.team == teamZombie: + yield o + +func senseRubble*(w: World, r: Robot, l: Loc): float64 = + ## `senseRubble` returns **-1** out of range rather than throwing. + if w.canSense(r, l): w.getRubble(l) else: -1.0 + +func senseParts*(w: World, r: Robot, l: Loc): float64 = + if w.canSense(r, l): w.getParts(l) else: -1.0 + +func initialArchonLocations*(w: World, t: Team): seq[Loc] = + ## `getInitialArchonLocations(t)`, sorted by `MapLocation.compareTo` and + ## PUBLIC FROM ROUND 0 to every robot of either faction. + for b in w.map.initialRobots: + if b.kind == ord(rtArchon) and b.team == ord(t): + result.add(loc(b.x, b.y)) + ## Insertion sort on the engine's own comparator — the roster is at most 4 + ## entries a side. + for i in 1 ..< result.len: + let v = result[i] + var j = i - 1 + while j >= 0 and compareLoc(result[j], v) > 0: + result[j + 1] = result[j] + dec j + result[j + 1] = v + +proc getNearestPlayerControlled*(w: World, l: Loc): Robot = + ## `GameWorld.getNearestPlayerControlled` (`:418-440`), and D2b lives here. + ## + ## Walk `gameObjectsByID.values()` in INSERTION ORDER, keep only + ## `team.isPlayer()` (A or B — never NEUTRAL, never ZOMBIE), track the + ## minimum `d2`, COLLECT EVERY LOCATION AT THAT MINIMUM, then return the + ## robot at `closest.get(rand.nextInt(closest.size()))`. THE DRAW HAPPENS ON + ## EVERY CALL, including when there is exactly one candidate, because + ## `java.util.Random.nextInt(1)` still consumes a `next(31)`. Getting that + ## condition wrong by one draw desynchronises the whole game. + var distSq = high(int) + var closest: seq[Loc] + for id in w.execOrder: + if not w.robotsById.hasKey(id): continue + let robot = w.robotsById[id] + if not robot.team.isPlayer(): continue + let newDistSq = robot.loc.distanceSquaredTo(l) + if newDistSq < distSq: + closest = @[robot.loc] + distSq = newDistSq + elif newDistSq == distSq: + closest.add(robot.loc) + if closest.len == 0: + return nil + let pick = int(w.rand.nextInt(closest.len)) + w.getRobot(closest[pick]) + +# --------------------------------------------------------------------------- +# Spawning, death and the ONE health mutation point +# --------------------------------------------------------------------------- + +proc placeRobot(w: World, r: Robot) = w.occupant[w.idx(r.loc)] = r +proc clearTile(w: World, l: Loc) = w.occupant[w.idx(l)] = nil + +proc denSpecIndexAt(w: World, l: Loc): int = + result = -1 + for i in 0 ..< w.map.dens.len: + if w.map.dens[i].x == l.x and w.map.dens[i].y == l.y: + return i + +proc setWinner*(w: World, t: Team, d: Domination) = + ## `GameWorld.setWinner`. `running` is NOT cleared here — the engine's own + ## line is commented out — so the round plays out. + w.winner = t + w.hasWinner = true + w.domination = d + +proc takePartsAt*(w: World, r: Robot): float64 {.discardable.} = + ## `GameWorld.takeParts`: zero the square and credit the WHOLE amount to + ## the archon's team. Called from exactly two places — an archon SPAWNING on + ## a parts square and an archon MOVING onto one — and for no other type. + if not w.onTheMap(r.loc): return 0.0 + let before = w.partsAt[w.idx(r.loc)] + w.partsAt[w.idx(r.loc)] = 0.0 + if before > 0.0: + w.adjustResources(r.team, before) + if r.team.isPlayer(): + w.stats.partsCollectedTenths[ord(r.team)] += int(before * 10.0) + w.stats.archonPartsWalks[ord(r.team)] += 1 + before + +proc spawnRobot*(w: World, kind: RobotType, l: Loc, t: Team, + buildDelay: int): Robot {.discardable.} = + ## `GameWorld.spawnRobot` -> `visitSpawnSignal` -> `InternalRobot`'s + ## constructor, in the engine's own order: take the next id, build the + ## robot at `type.maxHealth(currentRound)` with BOTH DELAYS AT ZERO and + ## `roundsAlive = 0`, bump the counts, append to the insertion order, place + ## it, and — for a PLAYER archon only — take the parts on the square. + let id = w.idGen.nextId() + var r = Robot(id: id, team: t, kind: kind, loc: l, + maxHealth: maxHealthOf(kind, w.currentRound), + attackPower: attackPowerOf(kind, w.currentRound), + d: initDelays(), roundsAlive: 0, buildDelay: buildDelay, + alive: true, denIndex: -1, + opsLeft: budgetFor(kind), taskLoc: loc(-1, -1)) + r.health = r.maxHealth + r.greenhornRng = initJavaRandom(2016) + if kind == rtZombieden: + r.denIndex = w.denSpecIndexAt(l) + w.robotsById[id] = r + w.execOrder.add(id) + w.typeCount[ord(t)][kind] += 1 + w.robotCount[ord(t)] += 1 + w.placeRobot(r) + if kind == rtArchon and t.isPlayer(): + w.takePartsAt(r) + if t == teamZombie and kind != rtZombieden: + w.stats.zombiesSpawned += 1 + r + +proc visitDeathSignal*(w: World, r: Robot, cause: DeathCause, + killer: Team = teamNeutral) = + ## `GameWorld.visitDeathSignal` (`:857-903`), in exactly this order: + ## (0) return immediately if `!running` — after the game ends deaths stop + ## being processed; + ## (a) decrement the type and robot counts, then: a PLAYER ARCHON whose + ## team now has ZERO archons and no winner set -> `setWinner(opponent, + ## DESTROYED)`, MID-TURN, with `running` still true; + ## (b) unless the cause is ACTIVATION and unless the robot is infected -> + ## `rubble += rubbleFactor * maxHealth`; + ## (c) remove from the id map, the exec order (BY VALUE) and the location + ## index; + ## (d) if the robot WAS infected and the cause is not ACTIVATION -> spawn + ## `turnsInto` on `Team.ZOMBIE` at the same square with + ## `maxHealth(currentRound)` — i.e. OUTBREAK-SCALED AT THE ROUND IT + ## TURNS, not the round it was built. + if not w.running: return + if not w.robotsById.hasKey(r.id): return + let t = r.team + let kind = r.kind + let l = r.loc + + w.typeCount[ord(t)][kind] -= 1 + w.robotCount[ord(t)] -= 1 + + if kind == rtArchon and t.isPlayer(): + if w.typeCount[ord(t)][rtArchon] == 0 and not w.hasWinner: + w.setWinner(t.opponent(), dfDestroyed) + + let consequence = deathConsequence(kind, r.maxHealth, cause, r.inf.isInfected()) + if consequence.rubbleAdded > 0.0: + w.alterRubble(l, w.getRubble(l) + consequence.rubbleAdded) + if t.isPlayer(): + w.stats.rubbleCreatedTenths[ord(t)] += int(consequence.rubbleAdded * 10.0) + + for k in 0 ..< w.execOrder.len: + if w.execOrder[k] == r.id: + w.execOrder.delete(k) + break + w.robotsById.del(r.id) + if w.getRobot(l) == r: + w.clearTile(l) + r.alive = false + + if t.isPlayer(): + w.stats.robotsLost[ord(t)] += 1 + w.lostThisRound[ord(t)] += 1 + if canAttack(kind): w.attackersLostThisRound[ord(t)] += 1 + if kind == rtArchon: + w.stats.archonsLost[ord(t)] += 1 + w.archonLostThisRound.add((team: ord(t), + cause: w.lastDamageSource[ord(t)])) + elif t == teamZombie and kind != rtZombieden: + w.stats.zombiesKilled += 1 + + if consequence.becomesZombie: + w.spawnRobot(consequence.zombieType, l, teamZombie, 0) + if t.isPlayer(): + w.stats.robotsTurned[ord(t)] += 1 + w.turnedThisRound.add((team: ord(t), kind: kind, + became: consequence.zombieType, l: l)) + +proc changeHealthLevel*(w: World, r: Robot, amount: float64, + source: DeathCause = dcNormal) = + ## `InternalRobot.changeHealthLevel` (`:278-293`) — THE SINGLE MUTATION + ## POINT FOR HEALTH, and re-entrant: an attack, a repair, the viper + ## infection tick and the den's proximity damage all come through here. + ## Cap at `maxHealth`, then destroy at `<= 0` with the TURRET flag carried + ## through to the rubble factor. + if not r.alive: return + r.health += amount + r.health = cappedHealth(r.health, r.maxHealth) + if isDead(r.health): + w.visitDeathSignal(r, source) + +proc takeDamage*(w: World, r: Robot, amount: float64, + attacker: RobotType, isTyped = true) = + ## `InternalRobot.takeDamage(baseAmount, attackerType)`. The death cause is + ## TURRET only when a TURRET landed the killing blow; every other attacker + ## and the untyped `takeDamage(double)` (the den's proximity damage and the + ## viper tick) pass `null`, which is the normal cause. + let cause = if isTyped and attacker == rtTurret: dcTurret else: dcNormal + w.changeHealthLevel(r, -amount, cause) + +# --------------------------------------------------------------------------- +# The actions of rule 3.2, in the engine's own order of definition +# --------------------------------------------------------------------------- + +proc noteFirstAction*(w: World, r: Robot, action: int) = + if not r.team.isPlayer(): return + let t = ord(r.team) + if w.firstActionSeen[t]: return + w.firstActionSeen[t] = true + discard w.beat(BeatFirstAction, "first_action", t, action) + +func canClearRubble*(w: World, r: Robot, d: Dir): bool = + ## `clearRubble`'s own assertions: core ready, the type can clear (NOT a + ## TURRET, NOT a TTM), the direction is not OMNI/NONE, and the target is on + ## the map. A square at EXACTLY 0 rubble returns silently and COSTS NOTHING, + ## which is a legal call and not a refusal — `doClearRubble` reproduces that + ## rather than counting it as an illegal order. + if not r.d.isCoreReady(): return false + if not canClearRubble(r.kind): return false + if d == dOmni or d == dNone: return false + w.onTheMap(r.loc + d) + +proc doClearRubble*(w: World, r: Robot, d: Dir): bool {.discardable.} = + if not w.canClearRubble(r, d): + w.refusedActions += 1 + return false + let target = r.loc + d + let before = w.getRubble(target) + if before == 0.0: + ## `RobotControllerImpl.clearRubble:459-461` — returns before charging. + return true + w.alterRubble(target, rubbleAfterClear(before)) + let after = w.getRubble(target) + if r.team.isPlayer(): + w.stats.rubbleClearedTenths[ord(r.team)] += int((before - after) * 10.0) + if before >= RubbleObstructionThresh and after < RubbleObstructionThresh: + w.stats.squaresOpened[ord(r.team)] += 1 + r.d.activateCoreAction(RobotSpecs[r.kind].cooldownDelay, + RobotSpecs[r.kind].movementDelay) + w.noteFirstAction(r, Bc16ActionClearRubble) + true + +func canMove*(w: World, r: Robot, d: Dir): bool = + ## `RobotControllerImpl.canMove`: the type can move (NOT a ZOMBIEDEN, NOT a + ## TURRET), the direction is a real one, and the destination is pathable. + ## NOTE: `canMove` does NOT test core readiness — `move()` asserts it + ## separately, and the zombie AI calls `canMove` after its own readiness + ## check, so the two are kept apart here exactly as the engine keeps them. + if not canMoveType(r.kind): return false + if d == dOmni or d == dNone: return false + w.canMoveTo(r.loc + d, r.kind) + +proc doMove*(w: World, r: Robot, d: Dir): bool {.discardable.} = + ## `RobotControllerImpl.move`: core-ready, movable, valid direction, + ## pathable; then `factor1` (diagonal, 1.4) and `factor3` (destination + ## rubble >= 50 and the mover does not ignore rubble, 2.0); the robot moves + ## and, IF IT IS AN ARCHON, takes every part on the destination; then + ## `setWeaponDelayUpTo(cooldownDelay * factor3)` and + ## `coreDelay += movementDelay * factor1 * factor3`. + ## + ## THE DIAGONAL MULTIPLIER HITS THE CORE DELAY ONLY: a soldier stepping + ## diagonally onto rubble 60 pays core 5.6 and weapon 2.0. + if not r.d.isCoreReady() or not w.canMove(r, d): + w.refusedActions += 1 + return false + let dest = r.loc + d + let factor1 = moveFactor1(d) + let factor3 = moveFactor3(w.getRubble(dest), r.kind) + w.clearTile(r.loc) + r.loc = dest + w.placeRobot(r) + if r.kind == rtArchon and r.team.isPlayer(): + w.takePartsAt(r) + r.d.setWeaponDelayUpTo(RobotSpecs[r.kind].cooldownDelay * factor3) + r.d.addCoreDelay(RobotSpecs[r.kind].movementDelay * factor1 * factor3) + w.noteFirstAction(r, Bc16ActionMove) + true + +func canAttackLocation*(w: World, r: Robot, l: Loc): bool = + ## `canAttackLocation`: the type can attack and the square is inside the + ## radius (and outside r2 6 for a TURRET). NO readiness test, NO vision + ## test, NO on-the-map test and NO target test — all four absences are the + ## engine's. + canAttack(r.kind) and w.canAttackSquare(r, l) + +proc doAttack*(w: World, r: Robot, l: Loc): bool {.discardable.} = + ## `attackLocation` -> `visitAttackSignal` FIRST, then + ## `activateAttack(attackDelay, cooldownDelay)`. + ## + ## The signal collects `getAllRobotsWithinRadiusSq(loc, 0)` — SPLASH RADIUS + ## ZERO — so exactly the one robot on that square or none, and then: + ## GUARD attacker vs zombie -> `rate = 2.0`; attacker `canInfect()` and + ## target `isInfectable()` -> INFECTED; `damage = attackPower * rate`, and a + ## GUARD target hit for more than 10.0 takes `damage - 4.0`; a ZOMBIEDEN + ## brought to `<= 0` pays the attacker's team 200 parts. + if not r.d.isWeaponReady() or not w.canAttackLocation(r, l): + w.refusedActions += 1 + return false + let target = w.getRobot(l) + if target != nil: + let rate = guardRate(r.kind, target.kind) + if canInfect(r.kind) and isInfectable(target.kind): + ## The engine re-sets the counter on EVERY hit; the telemetry counts a + ## NEW infection only, so `infections_suffered` is a count of units + ## infected and not of bites landed. + let wasInfected = target.inf.isInfected() + target.inf.setInfected(r.kind) + if not wasInfected: + if target.team.isPlayer(): + w.stats.infectionsSuffered[ord(target.team)] += 1 + if r.team.isPlayer(): + w.stats.infectionsInflicted[ord(r.team)] += 1 + if target.team.isPlayer(): + discard w.beat(BeatInfection, "infection", ord(target.team), + ord(target.kind), + (if r.kind == rtViper: 0 else: 1), + $target.inf.infectedTurns()) + let raw = r.attackPower * rate + let dealt = damageToTarget(raw, target.kind) + let before = target.health + if target.team.isPlayer(): + w.lastDamageSource[ord(target.team)] = + (if r.team == teamZombie: (if r.kind == rtZombieden: "den_proximity" + else: "zombie") + else: "enemy") + w.takeDamage(target, dealt, r.kind) + let after = if target.alive: target.health else: 0.0 + let done = max(0.0, before - after) + if r.team.isPlayer(): + let t = ord(r.team) + w.stats.damageDealt[t] += int(done) + if target.team == teamZombie: + w.stats.zombieDamageDealt[t] += int(done) + if target.kind == rtZombieden: + w.stats.denDamageDealt[t] += int(done) + elif target.team.isPlayer() and target.team != r.team: + w.stats.enemyDamageDealt[t] += int(done) + if target.team.isPlayer(): + let t = ord(target.team) + if r.team == teamZombie: w.stats.zombieDamageTaken[t] += int(done) + elif r.team.isPlayer() and r.team != target.team: + w.stats.enemyDamageTaken[t] += int(done) + ## The den bounty: `target.getHealthLevel() <= 0.0` AFTER the damage. + if target.kind == rtZombieden and target.health <= 0.0: + w.adjustResources(r.team, DenPartReward) + if r.team.isPlayer(): + w.stats.densDestroyed[ord(r.team)] += 1 + var densLeft = 0 + for team in [teamZombie]: + densLeft += w.robotTypeCount(team, rtZombieden) + var queueDeleted = 0 + if target.denIndex >= 0: + for row in w.map.dens[target.denIndex].schedule: + if row.round > w.currentRound: + for c in row.counts: queueDeleted += c + discard w.beat(BeatDenDestroyed, "den_destroyed", ord(r.team), + l.x * 100 + l.y, densLeft, + $int(DenPartReward) & ":" & $queueDeleted) + r.d.activateAttack(RobotSpecs[r.kind].attackDelay, + RobotSpecs[r.kind].cooldownDelay) + w.noteFirstAction(r, Bc16ActionAttack) + true + +func canBuild*(w: World, r: Robot, d: Dir, kind: RobotType): bool = + ## `RobotControllerImpl.canBuild`: for a ZOMBIEDEN it is ONLY + ## `isEmpty(loc)`; for an ARCHON it is pathability for the NEW type plus the + ## build requirements (the builder can build, the type is buildable, the + ## team can afford it, and `spawnSource == builder type`). + let l = r.loc + d + if d == dOmni or d == dNone: return false + if r.kind == rtZombieden: + return w.isEmpty(l) + if not canBuildType(r.kind): return false + if not isBuildable(kind): return false + if RobotSpecs[kind].spawnSource != ord(r.kind): return false + if float64(kind.partCost()) > w.teamParts(r.team): return false + w.canMoveTo(l, kind) + +proc doBuild*(w: World, r: Robot, d: Dir, kind: RobotType): bool + {.discardable.} = + ## `build` -> `visitBuildSignal` (deduct `partCost`, spawn with + ## `buildDelay = buildTurns`) and THEN + ## `activateCoreAction(buildTurns, buildTurns)` — so an archon building a + ## VIPER is frozen 30 of its own turns. + if not r.d.isCoreReady() or not w.canBuild(r, d, kind): + w.refusedActions += 1 + return false + let l = r.loc + d + let delay = kind.buildTurns() + w.adjustResources(r.team, -float64(kind.partCost())) + if r.team.isPlayer(): + w.stats.partsSpentTenths[ord(r.team)] += kind.partCost() * 10 + let spawned = w.spawnRobot(kind, l, r.team, delay) + if r.team.isPlayer(): + let t = ord(r.team) + if r.kind == rtArchon: + w.stats.unitsBuilt[t] += 1 + case kind + of rtScout: w.stats.scoutsBuilt[t] += 1 + of rtSoldier: w.stats.soldiersBuilt[t] += 1 + of rtGuard: w.stats.guardsBuilt[t] += 1 + of rtViper: w.stats.vipersBuilt[t] += 1 + of rtTurret: w.stats.turretsBuilt[t] += 1 + else: discard + if not w.unitMilestoneSeen[t][kind]: + w.unitMilestoneSeen[t][kind] = true + discard w.beat(BeatUnitMilestone, "unit_milestone", t, ord(kind), + w.robotTypeCount(r.team, kind)) + r.d.activateCoreAction(float64(delay), float64(delay)) + w.noteFirstAction(r, Bc16ActionBuild) + discard spawned + true + +func canActivate*(w: World, r: Robot, l: Loc): bool = + ## `activate`: ARCHON only, `d2 <= ARCHON_ACTIVATION_RANGE = 2`, a robot is + ## there, its team is NEUTRAL, and the core is ready. + if r.kind != rtArchon: return false + if r.loc.distanceSquaredTo(l) > ArchonActivationRange: return false + let target = w.getRobot(l) + if target == nil: return false + if target.team != teamNeutral: return false + r.d.isCoreReady() + +proc doActivate*(w: World, r: Robot, l: Loc): bool {.discardable.} = + ## `visitActivationSignal`: kill the neutral with cause ACTIVATION — which + ## skips BOTH the rubble deposit and the infection conversion — and spawn + ## the SAME TYPE on the activator's team with `buildDelay 0`, immediately + ## active. Cost: `setWeaponDelayUpTo(0)` and `coreDelay += movementDelay` + ## (an archon's 2). + if not w.canActivate(r, l): + w.refusedActions += 1 + return false + let target = w.getRobot(l) + let kind = target.kind + w.visitDeathSignal(target, dcActivation) + w.spawnRobot(kind, l, r.team, 0) + if r.team.isPlayer(): + let t = ord(r.team) + w.stats.neutralsActivated[t] += 1 + if kind == rtArchon: w.stats.neutralArchonsActivated[t] += 1 + discard w.beat(BeatNeutralActivated, "neutral_activated", t, ord(kind), + l.x * 100 + l.y, $w.stats.neutralsActivated[t]) + r.d.activateCoreAction(0.0, RobotSpecs[r.kind].movementDelay) + w.noteFirstAction(r, Bc16ActionActivate) + true + +func canRepair*(w: World, r: Robot, l: Loc): bool = + ## `repair`: ARCHON only; `canAttackSquare(self, loc)`, i.e. r2 <= 24; a + ## robot is there; it is on YOUR team; it is NOT an ARCHON; and + ## `repairCount < 1`. NO READINESS TEST OF EITHER KIND. + if r.kind != rtArchon: return false + if not w.canAttackSquare(r, l): return false + let target = w.getRobot(l) + if target == nil: return false + if target.team != r.team: return false + if target.kind == rtArchon: return false + r.repairCount < 1 + +proc doRepair*(w: World, r: Robot, l: Loc): bool {.discardable.} = + ## `InternalRobot.repair`: `repairCount++` and `changeHealthLevel(+1.0, + ## ARCHON)`. COSTS NO DELAY OF EITHER KIND — it never goes through + ## `activateCoreAction` — and it is the ONLY healing in the game. + if not w.canRepair(r, l): + w.refusedActions += 1 + return false + let target = w.getRobot(l) + let before = target.health + r.repairCount += 1 + w.changeHealthLevel(target, ArchonRepairAmount, dcNormal) + if r.team.isPlayer(): + let t = ord(r.team) + w.stats.repairs[t] += 1 + w.stats.hpRepaired[t] += int(max(0.0, target.health - before)) + w.noteFirstAction(r, Bc16ActionRepair) + true + +proc doTransform*(w: World, r: Robot): bool {.discardable.} = + ## `pack()` / `unpack()` -> `InternalRobot.transform`: swap the type, adjust + ## the per-type counts, and add `TURRET_TRANSFORM_DELAY = 10.0` TO BOTH + ## COUNTERS. THERE IS NO READINESS CHECK AT ALL, and `partCost` is not + ## charged again. + if r.kind != rtTurret and r.kind != rtTtm: + w.refusedActions += 1 + return false + let newKind = if r.kind == rtTurret: rtTtm else: rtTurret + w.typeCount[ord(r.team)][r.kind] -= 1 + w.typeCount[ord(r.team)][newKind] += 1 + let wasTurret = r.kind == rtTurret + r.kind = newKind + r.d.transformDelay() + if r.team.isPlayer() and wasTurret: + w.stats.turretPacks[ord(r.team)] += 1 + w.noteFirstAction(r, if wasTurret: Bc16ActionPack else: Bc16ActionUnpack) + true + +proc doDisintegrate*(w: World, r: Robot) = + ## `disintegrate()` throws `RobotDeathException`; the sandbox terminates and + ## `runRound` (`:178-181`) calls `suicide()` AFTER `processEndOfTurn`, as an + ## ordinary `DeathSignal` — so a robot that disintegrates WHILE INFECTED + ## still becomes an enemy zombie, and leaves rubble otherwise. + r.disintegrated = true + w.noteFirstAction(r, Bc16ActionDisintegrate) + +# --------------------------------------------------------------------------- +# Aggregates the end ladder, the score and the hash chain read +# --------------------------------------------------------------------------- + +func archonHealthTotal*(w: World, t: Team): float64 = + for id in w.execOrder: + if w.robotsById.hasKey(id): + let r = w.robotsById[id] + if r.team == t and r.kind == rtArchon: result += r.health + +func partsWorth*(w: World, t: Team): int = + ## `int(parts) + sum(partCost)` over that team's live robots — the + ## per-team reading of rung 3, for the score and the readouts. + result = int(w.resources[ord(t)]) + for id in w.execOrder: + if w.robotsById.hasKey(id): + let r = w.robotsById[id] + if r.team == t: result += r.kind.partCost() + +func partsNetWorthDiff*(w: World): float64 = + ## Rung 3 EXACTLY as `processEndOfRound` computes it (`:641-665`): the + ## accumulator is SEEDED with the parts difference and then walks every live + ## robot of either team ONCE, in insertion order, adding A's `partCost` and + ## subtracting B's. Neutrals and zombies belong to neither team and + ## contribute nothing; a den and a zombie cost 0 anyway. + result = w.resources[ord(teamA)] - w.resources[ord(teamB)] + for id in w.execOrder: + if w.robotsById.hasKey(id): + let r = w.robotsById[id] + if r.team == teamA: result += float64(r.kind.partCost()) + elif r.team == teamB: result -= float64(r.kind.partCost()) + +func highestArchonId*(w: World, t: Team): int = + ## `highestAArchonID` / `highestBArchonID`, which are 0 when that team has + ## no archon — and rung 4's `else` branch awards B, so a 0-vs-0 tie goes to + ## B. + for id in w.execOrder: + if w.robotsById.hasKey(id): + let r = w.robotsById[id] + if r.team == t and r.kind == rtArchon: result = max(result, r.id) + +func zombieCountByType*(w: World, k: RobotType): int = + w.typeCount[ord(teamZombie)][k] + +func densStanding*(w: World): int = w.typeCount[ord(teamZombie)][rtZombieden] + +func neutralsStanding*(w: World): int = w.robotCount[ord(teamNeutral)] + +func totalHealthTenths*(w: World, t: Team): int = + var total = 0.0 + for id in w.execOrder: + if w.robotsById.hasKey(id): + let r = w.robotsById[id] + if r.team == t: total += r.health + int(total * 10.0) + +func infectedCount*(w: World, t: Team): int = + for id in w.execOrder: + if w.robotsById.hasKey(id): + let r = w.robotsById[id] + if r.team == t and r.inf.isInfected(): result += 1 + +func impassableSquares*(w: World): int = + for v in w.rubble: + if v >= RubbleObstructionThresh: result += 1 + +func partsOnMap*(w: World): float64 = + for v in w.partsAt: result += v + +func rubbleChecksum*(w: World): uint64 = + ## y ascending outer, x ascending inner — i.e. the array's own index order, + ## which is `SquareArray.Double`'s `y * width + x` (D5). Folded in TENTHS so + ## the value is an integer on both sides of the parity diff. + var flat = newSeq[int](w.rubble.len) + for i, v in w.rubble: flat[i] = int(v * 10.0) + fnv1a64(flat) + +func partsChecksum*(w: World): uint64 = + var flat = newSeq[int](w.partsAt.len) + for i, v in w.partsAt: flat[i] = int(v * 10.0) + fnv1a64(flat) + +func execOrderChecksum*(w: World): uint64 = fnv1a64(w.execOrder) + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +proc newWorld*(spec: MapSpec, maxRounds: int): World = + ## `GameWorld`'s constructor, in its own order: `currentRound = -1`, the + ## `IDGenerator` seeded from the map, BOTH TEAMS credited + ## `PARTS_INITIAL_AMOUNT = 300.0` ONCE EACH, the rubble and parts arrays + ## copied out of the map, then the initial robots spawned IN FILE ORDER, and + ## finally `rand = new Random(mapSeed)`. + ## + ## The initial robots are NOT sorted by id (unlike 2022): ids come from the + ## `IDGenerator` and are shuffled, while the ORDER is the file's. + let size = spec.width * spec.height + result = World(map: spec, width: spec.width, height: spec.height, + currentRound: -1, maxRounds: maxRounds, running: true, + symmetry: spec.symmetry, + rubble: spec.rubble, partsAt: spec.parts, + occupant: newSeq[Robot](size), + robotsById: initTable[int, Robot](), + hashChain: 0xcbf29ce484222325'u64, + winner: teamA, domination: dfNone, tiebreakRung: 0) + result.idGen = initIdGenerator(spec.randomSeed, 0) + when defined(bc16BrokenChassis): + result.brokenChassis = true + result.adjustResources(teamA, PartsInitialAmount) + result.adjustResources(teamB, PartsInitialAmount) + for b in spec.initialRobots: + result.spawnRobot(RobotType(b.kind), loc(b.x, b.y), Team(b.team), 0) + result.rand = initJavaRandom(spec.randomSeed) + result.zombieRand = initJavaRandom(spec.randomSeed) + for t in 0 .. 1: + result.stats.archonsStart[t] = result.typeCount[t][rtArchon] diff --git a/src/battlecode/years/bc16/zombies.nim b/src/battlecode/years/bc16/zombies.nim new file mode 100644 index 0000000..01f4dfb --- /dev/null +++ b/src/battlecode/years/bc16/zombies.nim @@ -0,0 +1,165 @@ +## The bc16 zombie AI, ported VERBATIM from +## `world/control/ZombieControlProvider.java` (398 lines) at commit +## `11a0b09f26a70da19f33a61ebec4ceaf6e161aa3`. +## +## **THIS FILE IS THE SIM, NOT A CHASSIS.** The zombie half of this game is +## engine-side: a den and a zombie cost nothing against any `DecisionOps` +## budget, take no doctrine, and are the same for both factions. It is also +## the half a doctrine has to plan around, so it is specified action for +## action — and it is the reason Tier A of the parity oracle is a large tier +## even with an idle player bot. +## +## Three things in here are the most order-sensitive code in the module: +## +## * **A den's turn** (`processZombieDen`, `:140-176`): add THIS DEN'S OWN +## share of this round's schedule into its persistent queue, +## `spawnAllPossible`, and then — ONLY IF ANY TYPE IS STILL QUEUED — damage +## every non-zombie robot on the eight adjacent squares for +## `DEN_SPAWN_PROXIMITY_DAMAGE = 10.0` and call `spawnAllPossible` AGAIN. +## So a den spawns at most 8 per call and AT MOST 16 PER ROUND. +## * **`spawnAllPossible`** (`:184-218`): walk the ring +## `DIRECTIONS[floorMod(start + dirOffset * chir, 8)]` for `dirOffset` +## 0..7, and per direction pick the next type as **the LAST type in +## {STANDARD, RANGED, FAST, BIG} with a non-zero count** — the loop has NO +## `break`, so the priority is really BIGZOMBIE, then FASTZOMBIE, then +## RANGEDZOMBIE, then STANDARDZOMBIE. `start` and `chir` are +## `getSpawnDirection` / `getSpawnChirality`, both memoised per location in +## the engine and both RESOLVED AT BUILD TIME here (D3/D4) and carried in +## the converted map file. +## * **A zombie's turn** (`processZombie`, `:220-300`) — eight steps with +## every early return in place, and TWO RNG DRAWS whose preconditions are +## exact (D2c): `random.nextInt(8)` ONLY when no player robot is alive at +## all, and `random.nextBoolean()` ONLY when the zombie got past the attack +## branch, past the `!isCoreReady()` branch and past the +## move-in-the-preferred-direction branch. `getNearestPlayerControlled` +## consumes a draw from the OTHER stream on every call (D2b). + +import world, signals + +export world + +proc addScheduledZombies*(w: World, den: Robot) = + ## `processZombieDen` step (a): this round's counts from THIS DEN'S OWN + ## split schedule (`getZombieSpawnSchedule(den.getLocation())`), added into + ## the den's persistent queue. The per-den split was computed at build time + ## (D3) and the runtime sim hashes nothing. + if den.denIndex < 0: return + for row in w.map.dens[den.denIndex].schedule: + if row.round == w.currentRound: + for i in 0 .. 3: + den.denQueue[i] += row.counts[i] + +func nextQueuedType(den: Robot): int = + ## The engine's own type loop, with NO `break`: it keeps the LAST non-zero + ## entry, so the effective priority is BIGZOMBIE, FASTZOMBIE, RANGEDZOMBIE, + ## STANDARDZOMBIE. Returns an index into `ZombieSpawnTypes`, or -1. + result = -1 + for i in 0 .. 3: + if den.denQueue[i] != 0: + result = i + +proc spawnAllPossible*(w: World, den: Robot) = + ## `spawnAllPossible` (`:184-218`). + if den.denIndex < 0: return + let start = w.map.dens[den.denIndex].spawnDir + let chir = w.map.dens[den.denIndex].chirality + for dirOffset in 0 .. 7: + ## `Math.floorMod(startingDirection + dirOffset * chirality, 8)`. + let raw = start + dirOffset * chir + let d = MoveDirs[((raw mod 8) + 8) mod 8] + let next = den.nextQueuedType() + if next < 0: + break + let kind = ZombieSpawnTypes[next] + if w.canBuild(den, d, kind): + w.doBuild(den, d, kind) + den.denQueue[next] -= 1 + +proc processZombieDen*(w: World, den: Robot) = + ## `processZombieDen` (`:140-176`), in its three steps. + w.addScheduledZombies(den) + w.spawnAllPossible(den) + if den.nextQueuedType() >= 0: + ## A queue remains: damage every adjacent NON-ZOMBIE robot for 10.0 — + ## which reaches NEUTRALS and both factions alike — and then try again. + ## `takeDamage(double)` passes a null attacker type, so the death cause is + ## the normal one and a corpse here still leaves rubble. + for i in 0 .. 7: + let block0 = w.getRobot(den.loc + MoveDirs[i]) + if block0 != nil and block0.team != teamZombie: + if block0.team.isPlayer(): + w.stats.zombieDamageTaken[ord(block0.team)] += + int(DenSpawnProximityDamage) + w.lastDamageSource[ord(block0.team)] = "den_proximity" + w.changeHealthLevel(block0, -DenSpawnProximityDamage, dcNormal) + w.spawnAllPossible(den) + +proc processZombie*(w: World, z: Robot) = + ## `processZombie` (`:220-300`), the eight-step ladder with every early + ## return in place. The armageddon daytime clause of step (c) is not ported + ## (V4). + let closest = w.getNearestPlayerControlled(z.loc) # D2b: one draw + if closest != nil and w.canAttackLocation(z, closest.loc): + ## (b) In range: attack if the weapon is ready — AND RETURN EITHER WAY. + if z.d.isWeaponReady(): + w.doAttack(z, closest.loc) + return + if not z.d.isCoreReady(): + ## (c) Nothing else is possible this turn. + return + var preferred: Dir + if closest != nil: + ## (d) Walk at it. + preferred = z.loc.directionTo(closest.loc) + if w.canMove(z, preferred): + w.doMove(z, preferred) + return + else: + ## D2c, site 1: `random.nextInt(8)` ONLY when there is no player robot + ## alive anywhere on the map. + preferred = MoveDirs[int(w.zombieRand.nextInt(8))] + ## (e) D2c, site 2: `random.nextBoolean()`, consumed ONLY here. + let newLeft = w.zombieRand.nextBoolean() + let nextDir = if newLeft: preferred.rotateLeft() else: preferred.rotateRight() + if w.canMove(z, nextDir): + w.doMove(z, nextDir) + return + ## (f) The other 45 degrees. + let finalDir = if newLeft: preferred.rotateRight() + else: preferred.rotateLeft() + if w.canMove(z, finalDir): + w.doMove(z, finalDir) + return + ## (g) Dig, but only into an UNOCCUPIED on-map square at rubble >= 100. A + ## FASTZOMBIE or a BIGZOMBIE ignores rubble and so never reaches here with + ## an empty square in front of it; a STANDARDZOMBIE or a RANGEDZOMBIE digs. + let preferredTarget = z.loc + preferred + if (not w.isLocationOccupied(preferredTarget)) and + w.onTheMap(preferredTarget) and + w.senseRubble(z, preferredTarget) >= RubbleObstructionThresh: + w.doClearRubble(z, preferred) + return + ## (h) Eat the NEUTRAL standing in the way — which is what happens to a + ## neutral a faction did not activate in time. + if w.isLocationOccupied(preferredTarget): + let occupant = w.getRobot(preferredTarget) + if occupant != nil and occupant.team == teamNeutral: + if z.d.isWeaponReady(): + w.doAttack(z, preferredTarget) + return + +proc runZombieController*(w: World, r: Robot) = + ## `ZombieControlProvider.runRobot`. A NEUTRAL robot reaches the "somehow + ## controlling a non-zombie robot -> kill it" branch in the engine ONLY + ## because the reference server registers the zombie provider for + ## `Team.NEUTRAL`; the driver in `tools/oracle/bc16/Bc16Trace.java` + ## registers a `NullControlProvider` for NEUTRAL instead, which is the + ## behaviour a match really has (a neutral robot never acts), and this port + ## does the same: A NEUTRAL ROBOT TAKES ITS TURN AND DOES NOTHING. + ## `docs/PARITY.md` §bc16 records that requirement. + if r.kind == rtZombieden: + w.processZombieDen(r) + elif isZombieType(r.kind): + w.processZombie(r) + else: + discard diff --git a/src/battlecode/years/dispatch.nim b/src/battlecode/years/dispatch.nim index 529745c..f37adff 100644 --- a/src/battlecode/years/dispatch.nim +++ b/src/battlecode/years/dispatch.nim @@ -42,6 +42,10 @@ import bc22/maps as maps22 import bc22/rules as rules22 import bc22/world as world22 import bc22/chassis/kit as kit22 +import bc16/maps as maps16 +import bc16/rules as rules16 +import bc16/world as world16 +import bc16/chassis/kit as kit16 export registry @@ -54,6 +58,7 @@ type yBc25 = "bc25" yBc23 = "bc23" yBc22 = "bc22" + yBc16 = "bc16" Session* = ref object ## One game in progress, in whichever year's sim. `stepRound` advances it; @@ -89,6 +94,10 @@ type w22*: world22.World sides22*: array[2, kit22.Side] chassis22*: array[2, rules22.ChassisKind22] + of yBc16: + w16*: world16.World + sides16*: array[2, kit16.Side] + chassis16*: array[2, rules16.ChassisKind16] GameOutcome* = object ## The YEAR-NEUTRAL per-game outcome. `results.games[]`'s five required @@ -189,6 +198,28 @@ const Bc22RungNames* = ["-", "annihilated", "more_archons", "coin_flip"] ## `Domination`'s ordinals, for `singularity.rung`. +const Bc16ActionNames* = [ + "clear_rubble", "move", "attack", "broadcast", "broadcast_message", + "build", "activate", "repair", "pack", "unpack", "disintegrate" +] + ## bc16 has TWELVE unit types and eleven distinct actions, so its + ## `first_action` names the ACTION rather than the unit. And the field is + ## `action`, never `kind`: a field named `kind` is flattened into the same + ## object as the event's own `kind` key and silently overwrites it (the + ## bc23 r1-F25 finding). + +const Bc16UnitNames* = [ + "zombieden", "standardzombie", "rangedzombie", "fastzombie", "bigzombie", + "archon", "scout", "soldier", "guard", "viper", "turret", "ttm" +] + ## `RobotType`'s ordinals, for `unit_milestone.unit`, `turned.unit` and + ## `neutral_activated.unit`. + +const Bc16RungNames* = ["-", "archons_destroyed", "more_archons", + "more_archon_health", "more_parts_net_worth", + "highest_id"] + ## `Domination`'s ordinals, for `tiebreak.rung`. + const Bc25TowerNames* = ["paint", "money", "defense"] ## `TowerKind`'s ordinals, for `tower_built` / `tower_upgraded` / ## `tower_lost`. @@ -201,6 +232,7 @@ proc yearIdOf*(year: string): YearId = of "bc25": yBc25 of "bc23": yBc23 of "bc22": yBc22 + of "bc16": yBc16 else: yBc26 proc strongChassisFor*(year: string): ScriptedChassis = @@ -214,6 +246,7 @@ proc strongChassisFor*(year: string): ScriptedChassis = of yBc25: scSpaark of yBc23: scLemonade of yBc22: scWololo + of yBc16: scBulwark proc parseScriptedChassis*(name: string): ScriptedChassis = ## Year-free reading of a recorded `seats[].chassis` string. An unrecognised @@ -237,6 +270,7 @@ proc poolNamesFor*(year, pool: string): seq[string] = of yBc25: maps25.poolNames(pool) of yBc23: maps23.poolNames(pool) of yBc22: maps22.poolNames(pool) + of yBc16: maps16.poolNames(pool) proc drawMapsFor*(year, pool: string, seed, count: int): seq[string] = case yearIdOf(year) @@ -247,6 +281,7 @@ proc drawMapsFor*(year, pool: string, seed, count: int): seq[string] = of yBc25: maps25.drawMaps(pool, seed, count) of yBc23: maps23.drawMaps(pool, seed, count) of yBc22: maps22.drawMaps(pool, seed, count) + of yBc16: maps16.drawMaps(pool, seed, count) proc sideAslotFor*(year: string, seed, gameIndex: int): int = case yearIdOf(year) @@ -257,6 +292,7 @@ proc sideAslotFor*(year: string, seed, gameIndex: int): int = of yBc25: maps25.sideAslotFor(seed, gameIndex) of yBc23: maps23.sideAslotFor(seed, gameIndex) of yBc22: maps22.sideAslotFor(seed, gameIndex) + of yBc16: maps16.sideAslotFor(seed, gameIndex) proc mapPathFor*(year, name: string): string = case yearIdOf(year) @@ -267,6 +303,7 @@ proc mapPathFor*(year, name: string): string = of yBc25: maps25.mapPath(name) of yBc23: maps23.mapPath(name) of yBc22: maps22.mapPath(name) + of yBc16: maps16.mapPath(name) proc mapCardFor*(year, name: string, slot, sideAslot, rounds: int): JsonNode = ## The per-map facts a seat may legitimately know before writing its @@ -309,6 +346,10 @@ proc mapCardFor*(year, name: string, slot, sideAslot, rounds: int): JsonNode = var card = maps22.mapCard(maps22.loadMap(name), slot, sideAslot) card["rounds"] = %rounds card + of yBc16: + var card = maps16.mapCard(maps16.loadMap(name), slot, sideAslot) + card["rounds"] = %rounds + card # --------------------------------------------------------------------------- # Sessions @@ -380,6 +421,15 @@ proc newSession*(year: string, mapName: string, sheets: array[2, Sheet], rules22.chassisKindFor(chassis[1])] result.chassis22 = [kinds22[sideAslot], kinds22[1 - sideAslot]] result.sides22 = rules22.newSides22(sheets, sideAslot) + of yBc16: + let spec = maps16.loadMap(mapName) + result = Session(year: yBc16, mapName: mapName, sideAslot: sideAslot, + gameIndex: gameIndex) + result.w16 = world16.newWorld(spec, maxRounds) + let kinds16 = [rules16.chassisKindFor(chassis[0]), + rules16.chassisKindFor(chassis[1])] + result.chassis16 = [kinds16[sideAslot], kinds16[1 - sideAslot]] + result.sides16 = rules16.newSides16(sheets, sideAslot) proc stepRound*(s: Session) = case s.year @@ -390,6 +440,7 @@ proc stepRound*(s: Session) = of yBc25: rules25.runRound(s.w25, s.sides25, s.chassis25) of yBc23: rules23.runRound(s.w23, s.sides23, s.chassis23) of yBc22: rules22.runRound(s.w22, s.sides22, s.chassis22) + of yBc16: rules16.runRound(s.w16, s.sides16, s.chassis16) proc currentRound*(s: Session): int = case s.year @@ -400,6 +451,7 @@ proc currentRound*(s: Session): int = of yBc25: s.w25.currentRound of yBc23: s.w23.currentRound of yBc22: s.w22.currentRound + of yBc16: s.w16.currentRound proc running*(s: Session): bool = case s.year @@ -410,6 +462,7 @@ proc running*(s: Session): bool = of yBc25: s.w25.running of yBc23: s.w23.running of yBc22: s.w22.running + of yBc16: s.w16.running proc hashChainHex*(s: Session): string = case s.year @@ -420,6 +473,7 @@ proc hashChainHex*(s: Session): string = of yBc25: toHex(s.w25.hashChain) of yBc23: toHex(s.w23.hashChain) of yBc22: toHex(s.w22.hashChain) + of yBc16: toHex(s.w16.hashChain) proc mapWidth*(s: Session): int = case s.year @@ -430,6 +484,7 @@ proc mapWidth*(s: Session): int = of yBc25: s.w25.width of yBc23: s.w23.width of yBc22: s.w22.width + of yBc16: s.w16.width proc mapHeight*(s: Session): int = case s.year @@ -440,6 +495,7 @@ proc mapHeight*(s: Session): int = of yBc25: s.w25.height of yBc23: s.w23.height of yBc22: s.w22.height + of yBc16: s.w16.height # --------------------------------------------------------------------------- # Playing a game, and converting the year's outcome to the neutral one @@ -723,6 +779,76 @@ proc statsJson22*(o: rules22.GameOutcome22): JsonNode = "singularity_round": o.singularityRound } +proc statsJson16*(o: rules16.GameOutcome16): JsonNode = + %*{ + "archons_start": [o.archonsStart[0], o.archonsStart[1]], + "archons_end": [o.archonsEnd[0], o.archonsEnd[1]], + "archons_lost": [o.archonsLost[0], o.archonsLost[1]], + "archon_health_end_tenths": + [o.archonHealthEndTenths[0], o.archonHealthEndTenths[1]], + "parts_end_tenths": [o.partsEndTenths[0], o.partsEndTenths[1]], + "parts_worth_end": [o.partsWorthEnd[0], o.partsWorthEnd[1]], + "parts_collected_tenths": + [o.partsCollectedTenths[0], o.partsCollectedTenths[1]], + "parts_income_tenths": [o.partsIncomeTenths[0], o.partsIncomeTenths[1]], + "parts_spent_tenths": [o.partsSpentTenths[0], o.partsSpentTenths[1]], + "units_built": [o.unitsBuilt[0], o.unitsBuilt[1]], + "scouts_built": [o.scoutsBuilt[0], o.scoutsBuilt[1]], + "soldiers_built": [o.soldiersBuilt[0], o.soldiersBuilt[1]], + "guards_built": [o.guardsBuilt[0], o.guardsBuilt[1]], + "vipers_built": [o.vipersBuilt[0], o.vipersBuilt[1]], + "turrets_built": [o.turretsBuilt[0], o.turretsBuilt[1]], + "turret_packs": [o.turretPacks[0], o.turretPacks[1]], + "robots_alive": [o.robotsAlive[0], o.robotsAlive[1]], + "robots_lost": [o.robotsLost[0], o.robotsLost[1]], + "robots_turned": [o.robotsTurned[0], o.robotsTurned[1]], + "neutrals_activated": [o.neutralsActivated[0], o.neutralsActivated[1]], + "neutral_archons_activated": + [o.neutralArchonsActivated[0], o.neutralArchonsActivated[1]], + "dens_destroyed": [o.densDestroyed[0], o.densDestroyed[1]], + "den_damage_dealt": [o.denDamageDealt[0], o.denDamageDealt[1]], + "damage_dealt": [o.damageDealt[0], o.damageDealt[1]], + "zombie_damage_dealt": + [o.zombieDamageDealt[0], o.zombieDamageDealt[1]], + "zombie_damage_taken": + [o.zombieDamageTaken[0], o.zombieDamageTaken[1]], + "enemy_damage_dealt": [o.enemyDamageDealt[0], o.enemyDamageDealt[1]], + "enemy_damage_taken": [o.enemyDamageTaken[0], o.enemyDamageTaken[1]], + "infections_suffered": + [o.infectionsSuffered[0], o.infectionsSuffered[1]], + "infections_inflicted": + [o.infectionsInflicted[0], o.infectionsInflicted[1]], + "viper_infection_damage": + [o.viperInfectionDamage[0], o.viperInfectionDamage[1]], + "repairs": [o.repairs[0], o.repairs[1]], + "hp_repaired": [o.hpRepaired[0], o.hpRepaired[1]], + "rubble_cleared_tenths": + [o.rubbleClearedTenths[0], o.rubbleClearedTenths[1]], + "rubble_created_tenths": + [o.rubbleCreatedTenths[0], o.rubbleCreatedTenths[1]], + "squares_opened": [o.squaresOpened[0], o.squaresOpened[1]], + "basic_signals": [o.basicSignals[0], o.basicSignals[1]], + "message_signals": [o.messageSignals[0], o.messageSignals[1]], + "archon_parts_walks": [o.archonPartsWalks[0], o.archonPartsWalks[1]], + "archons_alive_at_2000": + [o.archonsAliveAt2000[0], o.archonsAliveAt2000[1]], + "archons_per_side": o.archonsPerSide, + "dens_per_side": o.densPerSide, + "dens_on_map": o.densOnMap, + "parts_on_map_start": o.partsOnMapStart, + "parts_squares_start": o.partsSquaresStart, + "rubble_mean_tenths": o.rubbleMeanTenths, + "impassable_squares_start": o.impassableSquaresStart, + "impassable_squares_end": o.impassableSquaresEnd, + "neutrals_on_map_start": o.neutralsOnMapStart, + "zombies_spawned": o.zombiesSpawned, + "zombies_alive_end": o.zombiesAliveEnd, + "zombies_killed": o.zombiesKilled, + "outbreak_level_end": o.outbreakLevelEnd, + "schedule_rounds": o.scheduleRounds, + "tiebreak_round": o.tiebreakRound + } + proc playGameFor*( year, mapName: string, sheets: array[2, Sheet], chassis: array[2, ScriptedChassis], @@ -800,6 +926,16 @@ proc playGameFor*( endReason: o.endReason, points: o.points, hashChain: o.hashChain, roundChains: o.roundChains, aborted: o.aborted, stats: statsJson22(o)), w.events) + of yBc16: + let spec = maps16.loadMap(mapName) + let (w, o) = rules16.playGame(spec, sheets, + [rules16.chassisKindFor(chassis[0]), rules16.chassisKindFor(chassis[1])], + index, sideAslot, maxRounds, budgetSeconds) + (GameOutcome(index: o.index, mapName: o.mapName, sideAslot: o.sideAslot, + roundsPlayed: o.roundsPlayed, winnerSlot: o.winnerSlot, + endReason: o.endReason, points: o.points, hashChain: o.hashChain, + roundChains: o.roundChains, aborted: o.aborted, + stats: statsJson16(o)), w.events) proc bc21Breakpoints*(): seq[int] = ## The slanderer influence breakpoints, for the bc21 doctrine brief. Read diff --git a/src/battlecode/years/registry.nim b/src/battlecode/years/registry.nim index a32bd1e..e29b8bb 100644 --- a/src/battlecode/years/registry.nim +++ b/src/battlecode/years/registry.nim @@ -44,7 +44,10 @@ const Years* = [ atlas: "atlas_bc23"), YearSpec(id: "bc22", title: "Battlecode 2022 — Mutation", maxRounds: 2000, pools: @["small", "mixed", "large"], - atlas: "atlas_bc22") + atlas: "atlas_bc22"), + YearSpec(id: "bc16", title: "Battlecode 2016 — Zombie Invasion", + maxRounds: 3000, pools: @["small", "mixed", "large"], + atlas: "atlas_bc16") ] proc yearSpec*(id: string): YearSpec = diff --git a/tools/build_sprite_atlas_bc16.py b/tools/build_sprite_atlas_bc16.py new file mode 100644 index 0000000..26094e3 --- /dev/null +++ b/tools/build_sprite_atlas_bc16.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Cut `data/atlas_bc16.png` + `data/atlas_bc16.json` from the 2016 client art. + +CI-TIME ONLY. The source is +`battlecode/battlecode-client-2016` at the pinned commit +`317e1f3ff902ae568619c051813335ecdd72322c`, directory +`src/main/battlecode/client/resources/art/`; the atlas is COMMITTED so the +runtime image and the wasm bundle carry no build step and no upstream tree. + + tools/build_sprite_atlas_bc16.py --client /path/to/battlecode-client-2016 \ + --out data + tools/build_sprite_atlas_bc16.py --client ... --out data --check + +LICENCE, RECORDED HONESTLY. `battlecode-client-2016/COPYING` is the GNU GPL +v3 (35 147 bytes). GPL-3.0 material may be combined with AGPL-3.0 material +under GPL-3.0 section 13, so this repository stays AGPL-3.0 and `NOTICE` +records the reasoning, the source repository, its commit and the exact +directory these sprites came from. NO CLIENT CODE IS SHIPPED, BUILT OR +EMBEDDED — only the images. + +WHAT MAKES THIS YEAR'S CUT DIFFERENT FROM EVERY OTHER ONE. The 2016 client +ships **four team variants of every one of the twelve robot types**: +`{archon,scout,soldier,guard,viper,turret,ttm,zombieden,standardzombie, +rangedzombie,fastzombie,bigzombie}{0,1,2,3}.png`, where the index is +`Team.values()` — 0 = A, 1 = B, 2 = NEUTRAL, 3 = ZOMBIE. So this is the FIRST +year in the repo whose art can draw a NEUTRAL ROBOT AS ITSELF rather than as a +greyed team sprite — which matters, because `neutral_activation` is a headline +knob and a spectator has to be able to see what is being activated. + +48 sprites (twelve types x four palettes) plus `creep.png` (the rubble +texture) are cut at 16 px, the viewer's native board scale. + +THE TERRAIN COLOURS ARE NOT IN HERE: `render.nim` carries this repository's own +rubble heat ramp — six steps with hard breaks at the two thresholds that +matter (50, where every charge doubles, and 100, where the square becomes +impassable) — and uses the atlas only for the units. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys + +from PIL import Image + +TILE = 16 +COLUMNS = 8 + +TYPES = ["zombieden", "standardzombie", "rangedzombie", "fastzombie", + "bigzombie", "archon", "scout", "soldier", "guard", "viper", + "turret", "ttm"] + +# `Team.values()`: A, B, NEUTRAL, ZOMBIE. +TEAM_SUFFIX = {"a": 0, "b": 1, "neutral": 2, "horde": 3} + + +def sprite_files() -> dict[str, str]: + out: dict[str, str] = {} + for kind in TYPES: + for team, index in TEAM_SUFFIX.items(): + out[f"{team}_{kind}"] = f"{kind}{index}.png" + out["creep"] = "creep.png" + return out + + +def build(art: pathlib.Path) -> tuple[Image.Image, dict]: + files = sprite_files() + names = sorted(files) + rows = (len(names) + COLUMNS - 1) // COLUMNS + sheet = Image.new("RGBA", (COLUMNS * TILE, rows * TILE), (0, 0, 0, 0)) + index: dict[str, dict[str, int]] = {} + for i, name in enumerate(names): + source = art / files[name] + if not source.exists(): + raise SystemExit(f"::error::missing 2016 client sprite: {source}") + cell = Image.open(source).convert("RGBA") + # The client's PNGs are square but not 16 px; downscale with a box + # filter so a 16 px board sprite keeps its silhouette. + cell = cell.resize((TILE, TILE), Image.LANCZOS) + x = (i % COLUMNS) * TILE + y = (i // COLUMNS) * TILE + sheet.paste(cell, (x, y)) + index[name] = {"x": x, "y": y, "w": TILE, "h": TILE} + return sheet, {"tile": TILE, "sprites": index} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--client", required=True, type=pathlib.Path, + help="a checkout of battlecode/battlecode-client-2016") + ap.add_argument("--out", required=True, type=pathlib.Path) + ap.add_argument("--check", action="store_true") + args = ap.parse_args() + + art = args.client / "src/main/battlecode/client/resources/art" + if not art.is_dir(): + sys.stderr.write(f"no 2016 client art under {art}\n") + return 1 + sheet, index = build(art) + png = args.out / "atlas_bc16.png" + meta = args.out / "atlas_bc16.json" + text = json.dumps(index, sort_keys=True, separators=(",", ":")) + "\n" + + if args.check: + if not meta.exists() or meta.read_text() != text: + sys.stderr.write("data/atlas_bc16.json differs from the " + "pinned 2016 client art\n") + return 1 + import io + buf = io.BytesIO() + sheet.save(buf, format="PNG", optimize=True) + if not png.exists() or png.read_bytes() != buf.getvalue(): + sys.stderr.write("data/atlas_bc16.png differs from the " + "pinned 2016 client art\n") + return 1 + print("the bc16 sprite atlas matches the pinned 2016 client art") + return 0 + + args.out.mkdir(parents=True, exist_ok=True) + sheet.save(png, format="PNG", optimize=True) + meta.write_text(text) + print(f"wrote {png} ({len(index['sprites'])} sprites) and {meta}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/convert_maps_bc16.py b/tools/convert_maps_bc16.py new file mode 100644 index 0000000..53ee3b4 --- /dev/null +++ b/tools/convert_maps_bc16.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +"""Convert Battlecode 2016 `.xml` maps to `data/maps/bc16/.json`. + +CI-TIME ONLY. There is no XML reader, no Python and no JVM in any runtime image +stage: the sim reads the committed JSON. `tests/test_bc16_maps.nim` asserts the +committed files against the design note's pinned table and the `test` job +re-runs this converter with `--check`, so a hand-edited map file fails the +build. + + tools/convert_maps_bc16.py --engine /path/to/battlecode-server-2016 \ + --out data/maps/bc16 + tools/convert_maps_bc16.py --engine ... --out data/maps/bc16 --check + tools/convert_maps_bc16.py --engine ... --parse-all # every .xml reads + +2016's map format is XML, not flatbuffers (`world/GameMap.java:31-42`, +`world/GameMapIO.java`), so this is a plain stdlib walk with no `flatc`, no +schema and no dependency: + + + x height # [y][x] rows + x height # [y][x] rows + + … + … + +THREE THINGS THIS CONVERTER RESOLVES AT BUILD TIME so the runtime sim never +has to (design note D3, D4 and V3): + +* **the map symmetry** (`GameMap.updateSymmetries`, `:529-636`): VERTICAL, + HORIZONTAL, ROTATIONAL and — only when `width == height` — + NEGATIVE_DIAGONAL and POSITIVE_DIAGONAL are each tested over the rubble and + parts arrays AND over the robot roster, and **the FIRST one that holds in + that order wins** (the engine warns and returns early on the second). Both + the winner and the whole found set are written out, and CI byte-diffs them + against the JVM's own `getSymmetry()`. +* **the per-den zombie spawn schedule** (`GameMap.buildZombieSpawnMap`, + `:729-801`) — THE ONE HASH-ORDER DEPENDENCY IN THE WHOLE 2016 ENGINE. The + den list is built by walking `byLoc.keySet()`, a `java.util.HashMap` keyed by + `MapLocation` (`hashCode() = x*13 + y*23`) and filled by `Collectors.toMap` + in initial-robot FILE order, and that order decides which den receives each + leftover zombie when a round's count does not divide evenly. `JavaHashMap` + below reproduces Java 8's implementation exactly — `h ^ (h >>> 16)`, bucket + `hash & (n-1)`, capacity 16, load factor 0.75, resize splitting each bucket + into its lo/hi lists in place, iteration walking buckets 0…n-1 and each chain + in insertion order — so the split is computed once here and the sim reads it. + `tools/JavaBc16HashOrder.java` cross-checks this emulation against a real + `java.util.HashMap`. +* **the two memoised den constants** `spawn_dir` and `chirality` + (`ZombieControlProvider.getSpawnDirection` `:313-342` / + `getSpawnChirality` `:350-385`), which are pure functions of the map. + +Coordinates are ORIGIN-RELATIVE (V3): the XML carries an `origin` attribute +(XStream sets the final field directly, so the constructor's random draw never +happens for a file-loaded map), the origin is added uniformly to every +coordinate, and every rule in the engine is translation invariant — so the +converter emits zero-origin coordinates and records the file's origin for +provenance only. + +The converter REFUSES a map that is `armageddon="true"` (V4 — both armageddon +maps are 2 archons vs 0 over 12 000 rounds), that has unequal archon counts, +that is outside 30…80 in either dimension, or whose `getSpawnDirection` would +be -1. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys +import xml.etree.ElementTree as ET + +# `RobotType.values()` order. The ordinal is load-bearing: `ZombieCount` +# sorts by it and the den's spawn priority reads it. +ROBOT_TYPES = ["ZOMBIEDEN", "STANDARDZOMBIE", "RANGEDZOMBIE", "FASTZOMBIE", + "BIGZOMBIE", "ARCHON", "SCOUT", "SOLDIER", "GUARD", "VIPER", + "TURRET", "TTM"] +TYPE_ORDINAL = {name: i for i, name in enumerate(ROBOT_TYPES)} + +# `Team.values()` order: A, B, NEUTRAL, ZOMBIE. +TEAMS = ["A", "B", "NEUTRAL", "ZOMBIE"] +TEAM_ORDINAL = {name: i for i, name in enumerate(TEAMS)} + +ZOMBIE_TYPES = ["STANDARDZOMBIE", "RANGEDZOMBIE", "FASTZOMBIE", "BIGZOMBIE"] + +# `ZombieControlProvider.DIRECTIONS`, in that order. `nextInt(8)` indexes it +# and `floorMod(start + i*chir, 8)` walks it. +DIRECTIONS = [("NORTH", 0, -1), ("NORTH_EAST", 1, -1), ("EAST", 1, 0), + ("SOUTH_EAST", 1, 1), ("SOUTH", 0, 1), ("SOUTH_WEST", -1, 1), + ("WEST", -1, 0), ("NORTH_WEST", -1, -1)] + +# `GameMap.Symmetry.values()` order, which is also the FIRST-WINS test order +# of `updateSymmetries`. +SYMMETRIES = ["VERTICAL", "HORIZONTAL", "ROTATIONAL", "NEGATIVE_DIAGONAL", + "POSITIVE_DIAGONAL", "NONE"] + +MAP_MIN = 30 +MAP_MAX = 80 + + +class RefuseMap(Exception): + """A map the converter will not convert, with the engine's own reason.""" + + +# --------------------------------------------------------------------------- +# java.util.HashMap iteration order (Java 8), for D3 +# --------------------------------------------------------------------------- + + +def _java_int(v: int) -> int: + v &= 0xFFFFFFFF + return v - 0x100000000 if v >= 0x80000000 else v + + +def _loc_hash(x: int, y: int) -> int: + """`MapLocation.hashCode()` == `x * 13 + y * 23`, as a Java int.""" + return _java_int(x * 13 + y * 23) + + +class JavaHashMap: + """Java 8's `HashMap` insertion + iteration order, for `MapLocation` keys. + + Only what `buildZombieSpawnMap` needs: `put` of distinct keys and a + `keySet()` walk. Bins are chains (never trees): treeification needs eight + keys in ONE bucket and the official rosters top out at 104 robots, so it + cannot be reached — and `--parse-all` asserts it for all 98 maps. + """ + + def __init__(self) -> None: + self.cap = 16 + self.threshold = 12 # 16 * 0.75 + self.size = 0 + self.table: list[list[tuple[int, tuple[int, int]]]] = [ + [] for _ in range(self.cap)] + + @staticmethod + def spread(h: int) -> int: + """`HashMap.hash(key)` == `h ^ (h >>> 16)`.""" + u = h & 0xFFFFFFFF + return (u ^ (u >> 16)) & 0xFFFFFFFF + + def put(self, key: tuple[int, int]) -> None: + h = self.spread(_loc_hash(key[0], key[1]) & 0xFFFFFFFF) + i = h & (self.cap - 1) + for entry in self.table[i]: + if entry[1] == key: + return + if len(self.table[i]) >= 7: + raise RefuseMap("a HashMap bucket would treeify; " + "the chain emulation is not valid here") + self.table[i].append((h, key)) + self.size += 1 + if self.size > self.threshold: + self._resize() + + def _resize(self) -> None: + """`HashMap.resize()`: each bin splits IN PLACE into a lo and a hi + list, preserving relative order, and the hi list lands at + `j + oldCap`.""" + old_cap = self.cap + new_cap = old_cap * 2 + new_table: list[list[tuple[int, tuple[int, int]]]] = [ + [] for _ in range(new_cap)] + for j in range(old_cap): + for entry in self.table[j]: + if entry[0] & old_cap: + new_table[j + old_cap].append(entry) + else: + new_table[j].append(entry) + self.cap = new_cap + self.threshold *= 2 + self.table = new_table + + def keys(self) -> list[tuple[int, int]]: + """`keySet()` iteration: buckets 0…n-1, each chain in insertion + order.""" + out = [] + for bucket in self.table: + for entry in bucket: + out.append(entry[1]) + return out + + +# --------------------------------------------------------------------------- +# Reading the XML +# --------------------------------------------------------------------------- + + +def _rows(node: ET.Element) -> list[list[float]]: + rows = [] + for arr in node.findall("double-array"): + text = (arr.text or "").strip() + rows.append([float(v) for v in text.split(",")] if text else []) + return rows + + +def _fmt(v: float) -> float: + """The exact decimal value, as an int when it is one. + + Every rubble and parts value in the 98 official maps is an integral + `double`; keeping them as ints makes the committed JSON small and the Nim + parse exact. A genuinely fractional value is kept as a float. + """ + return int(v) if v == int(v) else v + + +def read_map(path: pathlib.Path) -> dict: + root = ET.parse(path).getroot() + gm = root.find("game-map") + if gm is None: + raise RefuseMap("no element") + width = int(gm.attrib["width"]) + height = int(gm.attrib["height"]) + name = gm.attrib["mapName"] + seed = int(gm.attrib["seed"]) + rounds = int(gm.attrib.get("rounds", 3000)) + armageddon = gm.attrib.get("armageddon", "false") == "true" + origin = gm.attrib.get("origin", "0,0") + + rubble_rows = _rows(gm.find("initialRubble")) + parts_rows = _rows(gm.find("initialParts")) + if len(rubble_rows) != height or any(len(r) != width for r in rubble_rows): + raise RefuseMap("initialRubble is not height x width") + if len(parts_rows) != height or any(len(r) != width for r in parts_rows): + raise RefuseMap("initialParts is not height x width") + + schedule: list[tuple[int, dict[str, int]]] = [] + for rnd in gm.find("zombieSpawnSchedule").findall("round"): + number = int(rnd.attrib["number"]) + counts: dict[str, int] = {} + for zc in rnd.findall("zombie-count"): + kind = zc.attrib["type"] + counts[kind] = counts.get(kind, 0) + int(zc.attrib["count"]) + schedule.append((number, counts)) + # `ZombieSpawnSchedule.getRounds()` sorts, and `getScheduleForRound` sorts + # its counts by (type ordinal, count) — so the whole-map schedule is + # order-free and the converter canonicalises it here. + schedule.sort(key=lambda item: item[0]) + + robots = [] + for r in gm.find("initialRobots").findall("initial-robot"): + robots.append((int(r.attrib["originOffsetX"]), + int(r.attrib["originOffsetY"]), + r.attrib["type"], r.attrib["team"])) + + return { + "name": name, "width": width, "height": height, "seed": seed, + "rounds": rounds, "armageddon": armageddon, "origin": origin, + "rubble": rubble_rows, "parts": parts_rows, + "schedule": schedule, "robots": robots, + } + + +# --------------------------------------------------------------------------- +# Symmetry (D4) and the per-den split (D3) +# --------------------------------------------------------------------------- + + +def opposite(sym: str, x: int, y: int, width: int, height: int): + if sym == "VERTICAL": + return (x, height - y - 1) + if sym == "HORIZONTAL": + return (width - x - 1, y) + if sym == "ROTATIONAL": + return (width - x - 1, height - y - 1) + if sym == "NEGATIVE_DIAGONAL": + return (height - y - 1, width - x - 1) + if sym == "POSITIVE_DIAGONAL": + return (y, x) + return None + + +def opposite_robots(r1, r2) -> bool: + """`GameMap.oppositeRobots`, verbatim.""" + if r1 is None or r2 is None: + return False + if r1[2] != r2[2]: + return False + if r1[3] in ("ZOMBIE", "NEUTRAL"): + return r1[3] == r2[3] + return r2[3] not in ("ZOMBIE", "NEUTRAL") and r2[3] != r1[3] + + +def find_symmetries(m: dict) -> list[str]: + """`GameMap.updateSymmetries`, in the engine's own order. + + Returns every symmetry that holds, in `Symmetry.values()` order; the + engine takes the first (`symmetry` below) and warns about the rest. + """ + width, height = m["width"], m["height"] + rubble, parts = m["rubble"], m["parts"] + square = width == height + + def same_tile(x1, y1, x2, y2) -> bool: + return (rubble[y1][x1] == rubble[y2][x2] + and parts[y1][x1] == parts[y2][x2]) + + flags = {"VERTICAL": True, "HORIZONTAL": True, "ROTATIONAL": True, + "NEGATIVE_DIAGONAL": square, "POSITIVE_DIAGONAL": square} + for y in range(height): + for x in range(width): + if flags["VERTICAL"]: + flags["VERTICAL"] = same_tile(x, y, x, height - y - 1) + if flags["HORIZONTAL"]: + flags["HORIZONTAL"] = same_tile(x, y, width - x - 1, y) + if flags["ROTATIONAL"]: + flags["ROTATIONAL"] = same_tile( + x, y, width - x - 1, height - y - 1) + if square: + if flags["NEGATIVE_DIAGONAL"]: + flags["NEGATIVE_DIAGONAL"] = same_tile( + x, y, height - y - 1, width - x - 1) + if flags["POSITIVE_DIAGONAL"]: + flags["POSITIVE_DIAGONAL"] = same_tile(x, y, y, x) + + # The robot roster. `byLoc` here is a plain dict: `updateSymmetries` only + # LOOKS UP in it (it walks `keySet()` but the conjunction it builds is + # order-free), unlike `buildZombieSpawnMap`, which is order-DEPENDENT. + by_loc = {(r[0], r[1]): r for r in m["robots"]} + for (x, y), r1 in by_loc.items(): + for sym in list(flags): + if not flags[sym]: + continue + opp = opposite(sym, x, y, width, height) + flags[sym] = opposite_robots(r1, by_loc.get(opp)) + return [s for s in SYMMETRIES if s != "NONE" and flags.get(s)] + + +def den_locations(m: dict, symmetry: str) -> list[tuple[int, int]]: + """`buildZombieSpawnMap`'s `denLocs`, in the engine's own PAIR order. + + The `byLoc.keySet()` walk is a real `java.util.HashMap` iteration and is + what this function exists to reproduce (D3). + """ + hm = JavaHashMap() + for r in m["robots"]: + hm.put((r[0], r[1])) + by_loc = {(r[0], r[1]): r for r in m["robots"]} + + dens: list[tuple[int, int]] = [] + for loc in hm.keys(): + r1 = by_loc[loc] + if r1[2] != "ZOMBIEDEN" or loc in dens: + continue + dens.append(loc) + opp = opposite(symmetry, loc[0], loc[1], m["width"], m["height"]) + if (opp is not None and opposite_robots(r1, by_loc.get(opp)) + and opp not in dens): + dens.append(opp) + return dens + + +def split_schedule(m: dict, dens: list[tuple[int, int]]) -> list[dict]: + """`buildZombieSpawnMap`'s division: even shares plus a PERSISTENT cursor + handing out the leftovers, surviving across types AND across rounds.""" + per_den: list[dict[int, dict[str, int]]] = [{} for _ in dens] + if not dens: + return per_den + cursor = 0 + n = len(dens) + for rnd, counts in m["schedule"]: + # `getScheduleForRound` sorts by (type ordinal, count). + for kind in sorted(counts, key=lambda k: (TYPE_ORDINAL[k], + counts[k])): + count = counts[kind] + even, left = divmod(count, n) + for i in range(n): + if even: + slot = per_den[i].setdefault(rnd, {}) + slot[kind] = slot.get(kind, 0) + even + for _ in range(left): + slot = per_den[cursor].setdefault(rnd, {}) + slot[kind] = slot.get(kind, 0) + 1 + cursor = (cursor + 1) % n + return per_den + + +def direction_to(fx: int, fy: int, tx: int, ty: int) -> str: + """`MapLocation.directionTo`, with the engine's own 2.414 fan.""" + dx = float(tx - fx) + dy = float(ty - fy) + if abs(dx) >= 2.414 * abs(dy): + if dx > 0: + return "EAST" + if dx < 0: + return "WEST" + return "OMNI" + if abs(dy) >= 2.414 * abs(dx): + return "SOUTH" if dy > 0 else "NORTH" + if dy > 0: + return "SOUTH_EAST" if dx > 0 else "SOUTH_WEST" + return "NORTH_EAST" if dx > 0 else "NORTH_WEST" + + +def spawn_direction(m: dict, den: tuple[int, int]) -> int: + """`getSpawnDirection`: the direction to the closest INITIAL ARCHON of + either team, first minimum in FILE order (`Stream.min` keeps the earlier + element on a tie), as an index into `DIRECTIONS`.""" + best = None + best_d = None + for r in m["robots"]: + if r[2] != "ARCHON": + continue + d = (r[0] - den[0]) ** 2 + (r[1] - den[1]) ** 2 + if best_d is None or d < best_d: + best_d = d + best = (r[0], r[1]) + if best is None: + best = (m["width"] // 2, m["height"] // 2) + name = direction_to(den[0], den[1], best[0], best[1]) + for i, (dir_name, _, _) in enumerate(DIRECTIONS): + if dir_name == name: + return i + raise RefuseMap(f"getSpawnDirection would be -1 for den {den} ({name})") + + +def spawn_chirality(m: dict, den: tuple[int, int], symmetry: str) -> int: + """`getSpawnChirality`: 1 for ROTATIONAL and NONE, otherwise + `signum(loc.compareTo(opposite))` and 1 when that is 0 (a den on the line + of symmetry). `compareTo` is x-then-y and translation invariant, so the + origin does not matter (V3).""" + if symmetry in ("ROTATIONAL", "NONE"): + return 1 + opp = opposite(symmetry, den[0], den[1], m["width"], m["height"]) + if opp is None: + return 1 + cmp = (den[0] - opp[0]) if den[0] != opp[0] else (den[1] - opp[1]) + if cmp > 0: + return 1 + if cmp < 0: + return -1 + return 1 + + +# --------------------------------------------------------------------------- +# Conversion +# --------------------------------------------------------------------------- + + +def convert(path: pathlib.Path) -> dict: + m = read_map(path) + if m["armageddon"]: + raise RefuseMap("armageddon map (V4): a 2-vs-0 survival mode, " + "not a two-seat match") + if not (MAP_MIN <= m["width"] <= MAP_MAX + and MAP_MIN <= m["height"] <= MAP_MAX): + raise RefuseMap(f"{m['width']}x{m['height']} is outside " + f"{MAP_MIN}..{MAP_MAX}") + archons = {"A": 0, "B": 0} + for r in m["robots"]: + if r[2] == "ARCHON" and r[3] in archons: + archons[r[3]] += 1 + if archons["A"] != archons["B"]: + raise RefuseMap(f"unequal archon counts A={archons['A']} " + f"B={archons['B']}") + if archons["A"] == 0: + raise RefuseMap("no player archons") + + found = find_symmetries(m) + symmetry = found[0] if found else "NONE" + dens = den_locations(m, symmetry) + splits = split_schedule(m, dens) + + den_rows = [] + for den, split in zip(dens, splits): + den_rows.append({ + "x": den[0], "y": den[1], + "spawn_dir": spawn_direction(m, den), + "chirality": spawn_chirality(m, den, symmetry), + "schedule": {str(rnd): {k: v for k, v in + sorted(split[rnd].items(), + key=lambda kv: + TYPE_ORDINAL[kv[0]]) + if v} + for rnd in sorted(split)}, + }) + + return { + "name": m["name"], + "width": m["width"], + "height": m["height"], + "random_seed": m["seed"], + "rounds": m["rounds"], + "symmetry": symmetry.lower(), + "symmetries_found": [s.lower() for s in found], + "file_origin": m["origin"], + "rubble": [[_fmt(v) for v in row] for row in m["rubble"]], + "parts": [[_fmt(v) for v in row] for row in m["parts"]], + "initial_robots": [[r[0], r[1], TYPE_ORDINAL[r[2]], + TEAM_ORDINAL[r[3]]] for r in m["robots"]], + "schedule": [{"round": rnd, + "counts": {k: counts[k] for k in ZOMBIE_TYPES + if counts.get(k)}} + for rnd, counts in m["schedule"]], + "dens": den_rows, + } + + +def render(doc: dict) -> str: + return json.dumps(doc, sort_keys=True, separators=(",", ":")) + "\n" + + +def pool_names() -> list[str]: + pools = json.loads( + (pathlib.Path(__file__).with_name("map_pools_bc16.json")).read_text()) + return sorted({n for pool in pools.values() for n in pool}) + + +def summarise(doc: dict) -> tuple: + rubble = [v for row in doc["rubble"] for v in row] + parts = [v for row in doc["parts"] for v in row if v] + archons = sum(1 for r in doc["initial_robots"] + if r[2] == TYPE_ORDINAL["ARCHON"] and r[3] == 0) + neutrals = sum(1 for r in doc["initial_robots"] if r[3] == 2) + total_z = sum(sum(row["counts"].values()) for row in doc["schedule"]) + per_den = 0 + if doc["dens"]: + per_den = sum(sum(c.values()) + for c in doc["dens"][0]["schedule"].values()) + return ( + doc["name"], f"{doc['width']}x{doc['height']}", + doc["width"] * doc["height"], doc["random_seed"], doc["symmetry"], + "+".join(doc["symmetries_found"]), archons, len(doc["dens"]), + neutrals, round(sum(rubble) / len(rubble), 1), int(max(rubble)), + sum(1 for v in rubble if v >= 100), int(sum(parts)), len(parts), + int(max(parts)) if parts else 0, len(doc["schedule"]), + doc["schedule"][0]["round"] if doc["schedule"] else -1, + per_den, total_z, + ) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--engine", required=True, type=pathlib.Path, + help="a checkout of battlecode/battlecode-server-2016") + ap.add_argument("--out", type=pathlib.Path) + ap.add_argument("--only", nargs="*", default=None) + ap.add_argument("--check", action="store_true") + ap.add_argument("--parse-all", action="store_true", + help="read every .xml in the engine and print a table") + args = ap.parse_args() + + map_dir = args.engine / "src/main/battlecode/world/resources" + if not map_dir.is_dir(): + sys.stderr.write(f"no map resources under {map_dir}\n") + return 1 + + if args.parse_all: + rows = [] + refused = [] + for source in sorted(map_dir.glob("*.xml")): + try: + rows.append(summarise(convert(source))) + except RefuseMap as exc: + refused.append((source.stem, str(exc))) + for r in rows: + print("\t".join(str(v) for v in r)) + for name, why in refused: + print(f"REFUSED\t{name}\t{why}") + print(f"{len(rows) + len(refused)} official .xml files parsed, " + f"{len(refused)} refused", file=sys.stderr) + return 0 + + if args.out is None: + ap.error("--out is required unless --parse-all") + names = args.only or pool_names() + args.out.mkdir(parents=True, exist_ok=True) + drift = [] + for name in names: + source = map_dir / f"{name}.xml" + if not source.exists(): + sys.stderr.write(f"missing map at {args.engine}: {name}\n") + return 1 + text = render(convert(source)) + target = args.out / f"{name}.json" + if args.check: + if not target.exists() or target.read_text() != text: + drift.append(name) + else: + target.write_text(text) + if args.check: + if drift: + sys.stderr.write("converted bc16 maps differ from the engine: " + + ", ".join(drift) + "\n") + return 1 + print(f"{len(names)} converted bc16 maps match the engine") + else: + print(f"wrote {len(names)} maps to {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/gen_year_constants.py b/tools/gen_year_constants.py index 704e003..27327b0 100644 --- a/tools/gen_year_constants.py +++ b/tools/gen_year_constants.py @@ -13,8 +13,8 @@ --out src/battlecode/years/bc26/constants.nim tools/gen_year_constants.py --engine ... --check # diff, exit 1 on drift -`--year bc20`, `--year bc21`, `--year bc22`, `--year bc23`, `--year bc24` and -`--year bc25` do the same job +`--year bc20`, `--year bc21`, `--year bc22`, `--year bc23`, `--year bc24`, +`--year bc25` and `--year bc16` do the same job for the other year modules against a checkout of the matching engine at its pinned commit. bc20 and bc21 read `common/GameConstants.java` and `common/RobotType.java`; bc24 reads `common/GameConstants.java`, @@ -22,7 +22,9 @@ bc25 reads `common/GameConstants.java` and `common/UnitType.java`; bc23 reads `common/GameConstants.java`, `common/RobotType.java` and `common/Anchor.java`; bc22 reads `common/GameConstants.java`, `common/RobotType.java` and -`common/AnomalyType.java`: +`common/AnomalyType.java`; bc16 reads `common/GameConstants.java` and +`common/RobotType.java` (whose constants are INTERFACE fields, with no +`public static final` modifiers, so it needs its own regex): tools/gen_year_constants.py --year bc20 --engine /path/to/battlecode20 \ --out src/battlecode/years/bc20/constants.nim @@ -72,6 +74,10 @@ def nim_literal(java_type: str, raw: str) -> tuple[str, str]: "Integer.MAX_VALUE/2": "1073741823", # 2024's MAX_SHARED_ARRAY_VALUE, written as a shift. "(1<<16)-1": "65535", + # 2016's RUBBLE_FROM_TURRET_FACTOR, written as a division. The + # quotient is the double 1/3 rounds to, and `145 * (1.0/3.0) = + # 48.333333333333336` is a named parity vector because of it. + "1.0/3.0": "0.3333333333333333", } text = JAVA_EXPRESSIONS.get(text.replace(" ", ""), text) if re.fullmatch(r"[-+0-9._eE]+[LlFfDd]?", text): @@ -1002,11 +1008,201 @@ def render_bc22(engine: pathlib.Path) -> str: return "\n".join(lines) + "\n" +# --------------------------------------------------------------------------- +# bc16 -- Battlecode 2016 "Zombie Invasion" +# --------------------------------------------------------------------------- + +BC16_COMMIT = "11a0b09f26a70da19f33a61ebec4ceaf6e161aa3" + +# 2016's GameConstants is an INTERFACE, so its fields carry no +# `public static final` -- they are bare `int MAP_MIN_HEIGHT = 30;` +# declarations and CONST_RE (which requires the modifiers) sees none of them. +BC16_CONST_RE = re.compile( + r"^\s*(int|long|float|double|String)\s+([A-Z0-9_]+)\s*=\s*([^;]+);", + re.M) + +BC16_ROBOT_RE = re.compile( + r"^\s*(ZOMBIEDEN|STANDARDZOMBIE|RANGEDZOMBIE|FASTZOMBIE|BIGZOMBIE|" + r"ARCHON|SCOUT|SOLDIER|GUARD|VIPER|TURRET|TTM)\s*\((.*?)\)\s*[,;]\s*$", + re.M) + +BC16_OUTBREAK_RE = re.compile(r"case\s+(\d+):\s*return\s+([0-9.]+);") + +BC16_DECISION_OPS_WIDE = 2000 +BC16_DECISION_OPS_STANDARD = 1000 + # One tenth of `RobotType.bytecodeLimit` -- 20 000 for an ARCHON and a + # SCOUT, 10 000 for everything else -- the convention bc20..bc26 use. + # docs/RULES-BC16.md §Divergences items 1 and 2 carry the argument: the + # delay decay is pinned to 1.0 precisely so that no RULE reads the budget. + +BC16_TYPE_ORDINALS = { + "ZOMBIEDEN": 0, "STANDARDZOMBIE": 1, "RANGEDZOMBIE": 2, "FASTZOMBIE": 3, + "BIGZOMBIE": 4, "ARCHON": 5, "SCOUT": 6, "SOLDIER": 7, "GUARD": 8, + "VIPER": 9, "TURRET": 10, "TTM": 11, +} + + +def bc16_num(text: str) -> str: + """A `RobotType` constructor argument as a Nim literal. + + Every numeric column in the 2016 table is either an `int` or a `double`; + THERE IS NO FLOAT32 ANYWHERE IN THE 2016 RULE SET, so no widening is + needed here (unlike bc21/bc22/bc24/bc25) and a value like `2.5` is exactly + the double the JVM holds. + """ + t = text.strip() + if t in ("true", "false"): + return t + if t == "null": + return "-1" + if t in BC16_TYPE_ORDINALS: + return str(BC16_TYPE_ORDINALS[t]) + return t + + +def render_bc16(engine: pathlib.Path) -> str: + common = engine / "src/main/battlecode/common" + src = re.sub(r"//[^\n]*", "", (common / "GameConstants.java").read_text()) + consts = [] + for java_type, name, raw in BC16_CONST_RE.findall(src): + nim_type, literal = nim_literal(java_type, raw) + consts.append((name, nim_type, literal)) + if not consts: + raise SystemExit("::error::read no constants from the 2016 " + "GameConstants interface") + + robot_src = (common / "RobotType.java").read_text() + robots = [(n, [v.strip() for v in a.split(",")]) + for n, a in BC16_ROBOT_RE.findall(robot_src)] + if len(robots) != 12: + raise SystemExit( + f"::error::expected 12 RobotType entries, saw {len(robots)}") + for name, a in robots: + if len(a) != 17: + raise SystemExit( + f"::error::RobotType.{name} has {len(a)} arguments, " + "expected 17") + + outbreak = BC16_OUTBREAK_RE.findall(robot_src) + if len(outbreak) < 10: + raise SystemExit("::error::read fewer than ten outbreak levels from " + "RobotType.getOutbreakMultiplier") + ladder = {int(level): value for level, value in outbreak[:10]} + + lines: list[str] = [] + add = lines.append + add('## Battlecode 2016 "Zombie Invasion" gameplay constants ' + "-- GENERATED, do not edit.") + add("##") + add(f"## Source: github.com/battlecode/battlecode-server-2016 at commit " + f"`{BC16_COMMIT}`,") + add("## files `common/GameConstants.java` and `common/RobotType.java`,") + add("## read by `tools/gen_year_constants.py --year bc16`. The `test` job") + add("## of `.github/workflows/ci.yml` re-runs that generator with") + add("## `--check`, which byte-diffs this file, so an edit here fails the") + add("## build instead of quietly changing the rules under a `GameVersion`") + add("## that no longer describes them.") + add("##") + add("## THE OFFICIAL 2016 SPEC IS LOST (dead S3, dead battlecode.org, no") + add("## Wayback copy) and there is NO `SPEC_VERSION` field in this year's") + add("## `GameConstants` -- so the engine source IS the spec, this table is") + add("## its transcription, and the oracle jar is pinned by sha256 AND size") + add("## in `tools/oracle/bc16/jar.lock` instead of by a version string.") + add("##") + add("## 2016 IS A FLOAT64 YEAR: health, damage, both delay counters,") + add("## rubble, parts and every multiplier are Java `double`, and there is") + add("## NO float32 anywhere in the rule set. IEEE-754 binary64 add,") + add("## subtract, multiply, divide and compare are exactly specified and") + add("## identical on x86-64 SSE2 and on wasm32, so reproducing each") + add("## expression in the engine's own order is bit-exact by construction.") + add("## The two non-algebraic functions on gameplay paths --") + add("## `Math.pow(x, 1.5)` in `decrementDelays` and `(int) Math.sqrt(r2)`") + add("## in the radius scans -- both have finite domains and are TABLED in") + add("## `data/bc16/tables.json`, so the runtime path has no") + add("## transcendental at all.") + add("") + add(f'const EngineCommit* = "{BC16_COMMIT}"') + add('const OracleJarVersion* = "2016.0.2.2"') + add("") + add("type") + add(" RobotType* = enum") + add(" ## `common/RobotType.java` in `values()` order. THE ORDINAL IS") + add(" ## LOAD-BEARING: `ZombieCount.compareTo` sorts by it and the den's") + add(" ## spawn priority reads it (the no-`break` loop takes the LAST") + add(" ## non-zero type, so the priority is BIGZOMBIE, FASTZOMBIE,") + add(" ## RANGEDZOMBIE, STANDARDZOMBIE).") + for name, _ in robots: + add(f' rt{camel(name)} = "{name}"') + add("") + add(" RobotSpec* = object") + add(" ## `common/RobotType.java`'s seventeen constructor arguments, in") + add(" ## the file's own order. `spawnSource` and `turnsInto` are the") + add(" ## ORDINAL of the named type, or -1 for the engine's `null`.") + add(" isBuilding*, isZombie*: bool") + add(" infectTurns*, spawnSource*: int") + add(" partCost*, buildTurns*: int") + add(" maxHealth*, attackPower*: float64") + add(" attackRadiusSquared*: int") + add(" movementDelay*, attackDelay*, cooldownDelay*: float64") + add(" sensorRadiusSquared*, bytecodeLimit*, strengthWeight*: int") + add(" turnsInto*: int") + add(" ignoresRubble*: bool") + add("") + add("const") + for name, nim_type, literal in consts: + add(f" {camel(name)}*: {nim_type} = {literal}") + add("") + add(f" DecisionOpsWide*: int = {BC16_DECISION_OPS_WIDE}") + add(f" DecisionOpsStandard*: int = {BC16_DECISION_OPS_STANDARD}") + add(" ## Replace `RobotType.bytecodeLimit` outside the JVM: 2000 for an") + add(" ## ARCHON and a SCOUT, 1000 for everything else, 0 for a robot") + add(" ## with `!isActive()`. No mid-turn resumption, no mid-primitive") + add(" ## cut, enforced by the sim rather than by the bot.") + add("") + add(" RobotSpecs*: array[RobotType, RobotSpec] = [") + for name, a in robots: + add(f" rt{camel(name)}: RobotSpec(isBuilding: {bc16_num(a[0])}, " + f"isZombie: {bc16_num(a[1])},") + add(f" infectTurns: {bc16_num(a[2])}, " + f"spawnSource: {bc16_num(a[3])},") + add(f" partCost: {bc16_num(a[4])}, " + f"buildTurns: {bc16_num(a[5])},") + add(f" maxHealth: {bc16_num(a[6])}, " + f"attackPower: {bc16_num(a[7])},") + add(f" attackRadiusSquared: {bc16_num(a[8])},") + add(f" movementDelay: {bc16_num(a[9])}, " + f"attackDelay: {bc16_num(a[10])},") + add(f" cooldownDelay: {bc16_num(a[11])},") + add(f" sensorRadiusSquared: {bc16_num(a[12])}, " + f"bytecodeLimit: {bc16_num(a[13])},") + add(f" strengthWeight: {bc16_num(a[14])}, " + f"turnsInto: {bc16_num(a[15])},") + add(f" ignoresRubble: {bc16_num(a[16])}),") + add(" ]") + add("") + add(" OutbreakMultipliers*: array[13, float64] = [") + add(" ## `RobotType.getOutbreakMultiplier(round)`'s own switch for") + add(" ## levels 0..9, then its `default: 3.00 + (level - 9)` arm for") + add(" ## 10..12. `level = round / OUTBREAK_TIMER` (integer), applied to") + add(" ## a ZOMBIE's maxHealth and attackPower AT THE MOMENT IT SPAWNS") + add(" ## and never afterwards; a player unit never scales. A") + add(" ## 3000-round game's last round is 2999, so level 9 is the last") + add(" ## one a spawn actually reaches -- 10..12 are tabled anyway.") + for level in range(13): + value = ladder.get(level) + if value is None: + value = f"{3.0 + (level - 9):.2f}" + add(f" {float(value)!r},") + add(" ]") + add("") + return "\n".join(lines) + "\n" + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--year", default="bc26", choices=["bc26", "bc20", "bc21", "bc22", "bc23", "bc24", - "bc25"]) + "bc25", "bc16"]) ap.add_argument("--engine", required=True, type=pathlib.Path) ap.add_argument("--out", type=pathlib.Path, default=None) ap.add_argument("--check", action="store_true", @@ -1018,12 +1214,13 @@ def main() -> int: label = {"bc26": TAG, "bc20": BC20_COMMIT, "bc21": BC21_COMMIT, "bc22": BC22_COMMIT, "bc23": BC23_COMMIT, "bc24": BC24_COMMIT, - "bc25": BC25_COMMIT}[args.year] + "bc25": BC25_COMMIT, "bc16": BC16_COMMIT}[args.year] text = {"bc26": render, "bc20": render_bc20, "bc21": render_bc21, "bc22": render_bc22, "bc23": render_bc23, "bc24": render_bc24, - "bc25": render_bc25}[args.year](args.engine) + "bc25": render_bc25, + "bc16": render_bc16}[args.year](args.engine) if args.check: current = out.read_text() if out.exists() else "" if current != text: diff --git a/tools/map_pools_bc16.json b/tools/map_pools_bc16.json new file mode 100644 index 0000000..8aacf45 --- /dev/null +++ b/tools/map_pools_bc16.json @@ -0,0 +1,6 @@ +{ + "small": ["checkers", "zigzag", "swamp", "river", "prisons", "frogger"], + "mixed": ["closequarters", "lockdown", "industrial", "quadrants", "turtle", + "boxy", "voluted", "collision", "caverns", "6147"], + "large": ["desert", "space", "scouting", "vortex", "wormy", "quarry"] +} From 27adc9f57613e903aac28574bd5b470162af069d Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Wed, 9 Sep 2026 05:46:00 +0000 Subject: [PATCH 02/16] bc16: the manifest variant, the results keys and the four policies Adds the `bc16` variant to `coworld_manifest_template.json` beside the seven already there: `num_agents: 2` inside `game_config`, maxRounds 3000, the mixed pool, 3 games a match and the measured budgets. `config_schema.year` gains `"bc16"`; `game.docs.pages` gains a tenth page for `docs/RULES-BC16.md`; the two `player[]` descriptions name the bc16 chassis. `player[]` and the certification block stay on bc26, unchanged. ONE EDIT IS NOT ADDITIVE and is named here so a reviewer looks for it: `config_schema.maxRounds.maximum` goes 2000 -> 3000, a widened bound that accepts everything it accepted before. bc16's tiebreak fires at the end of round 2999, so 2000 would have refused the year's own default. `results.nim` gains `Bc16GameKeys` (44 keys, 10 of them reused from the year-neutral set) and three end reasons -- `archons_destroyed`, `more_archon_health`, `more_parts_net_worth`. `tools/ci/policies.json` gains bc16's four entries (two LLM champions, two scripted fillers), taking the repo-wide file to 32. --- coworld_manifest_template.json | 333 ++++++++++++++++++++++++++++++++- src/battlecode/results.nim | 43 ++++- tools/ci/policies.json | 37 ++++ 3 files changed, 406 insertions(+), 7 deletions(-) diff --git a/coworld_manifest_template.json b/coworld_manifest_template.json index 93cf54e..6b04777 100644 --- a/coworld_manifest_template.json +++ b/coworld_manifest_template.json @@ -9,7 +9,7 @@ "episode_timeout_minutes": 20, "game": { "name": "battlecode", - "description": "Battlecode, played by doctrine. Two cogs each write one sealed JSON strategy sheet and a deterministic Nim port of an official Battlecode rule set plays the whole match from those two sheets. Variant `bc26` is 2026 \"Uneasy Alliances\" — rat clans allied against NPC cats until one of them betrays. Variant `bc20` is 2020 \"Soup\" — the water rises every round, and a team either terraforms its way above the flood, walls its HQ in, or buries the enemy's under fifty units of dirt. Variant `bc21` is 2021 \"Campaign\" — Enlightenment Centers bid influence for votes and spend it on politicians, slanderers and muckrakers, and the election is decided at round 1500. Variant `bc24` is 2024 \"Breadwars\" — 50 identical ducks a side, three flags each, an impassable dam for 200 rounds, and traps you cannot see until they go off. Variant `bc25` is 2025 “Chromatic Conflict” — paint robots colour a grid, build money, paint and defense towers by painting exact patterns onto ruins, and win by owning 70 % of the map. Variant `bc23` is 2023 “Tempest” — carriers mine adamantium and mana from sky wells, launchers fight the only real war, and a faction wins by ferrying reality anchors onto 75 % of the sky islands. Variant `bc22` is 2022 “Mutation” — miners dig lead out of a map that only regenerates the squares you do not empty, laboratories turn lead into gold at a price that rises with company, and anomalies strike every two hundred rounds until the Singularity takes the weaker side at round 2000.", + "description": "Battlecode, played by doctrine. Two cogs each write one sealed JSON strategy sheet and a deterministic Nim port of an official Battlecode rule set plays the whole match from those two sheets. Variant `bc26` is 2026 \"Uneasy Alliances\" — rat clans allied against NPC cats until one of them betrays. Variant `bc20` is 2020 \"Soup\" — the water rises every round, and a team either terraforms its way above the flood, walls its HQ in, or buries the enemy's under fifty units of dirt. Variant `bc21` is 2021 \"Campaign\" — Enlightenment Centers bid influence for votes and spend it on politicians, slanderers and muckrakers, and the election is decided at round 1500. Variant `bc24` is 2024 \"Breadwars\" — 50 identical ducks a side, three flags each, an impassable dam for 200 rounds, and traps you cannot see until they go off. Variant `bc25` is 2025 “Chromatic Conflict” — paint robots colour a grid, build money, paint and defense towers by painting exact patterns onto ruins, and win by owning 70 % of the map. Variant `bc23` is 2023 “Tempest” — carriers mine adamantium and mana from sky wells, launchers fight the only real war, and a faction wins by ferrying reality anchors onto 75 % of the sky islands. Variant `bc22` is 2022 “Mutation” — miners dig lead out of a map that only regenerates the squares you do not empty, laboratories turn lead into gold at a price that rises with company, and anomalies strike every two hundred rounds until the Singularity takes the weaker side at round 2000. Variant `bc16` is 2016 \"Zombie Invasion\" — archons build soldiers, guards, vipers, turrets and scouts and collect parts while zombie dens spawn escalating waves on a public schedule, every kill by a zombie stands the victim back up on the horde's side, and every uninfected corpse becomes a wall; win by destroying the enemy's last archon, or by having more of them at round 2999.", "owner": "daveey@gmail.com", "replay_viewer": { "bundle": "static-replay-viewer" @@ -86,6 +86,14 @@ "value": "https://github.com/Metta-AI/cogame-battlecode/blob/main/docs/RULES-BC22.md" } }, + { + "id": "rules-bc16.md", + "title": "Battlecode 2016 \"Zombie Invasion\": rules, knobs and divergences", + "content": { + "type": "uri", + "value": "https://github.com/Metta-AI/cogame-battlecode/blob/main/docs/RULES-BC16.md" + } + }, { "id": "replay.md", "title": "Replay format", @@ -131,7 +139,8 @@ "bc24", "bc25", "bc23", - "bc22" + "bc22", + "bc16" ] }, "pool": { @@ -154,7 +163,7 @@ "maxRounds": { "type": "integer", "minimum": 50, - "maximum": 2000 + "maximum": 3000 }, "num_agents": { "type": "integer", @@ -350,7 +359,10 @@ "more_adamantium_net_worth", "more_archons", "more_gold_net_worth", - "more_lead_net_worth" + "more_lead_net_worth", + "archons_destroyed", + "more_archon_health", + "more_parts_net_worth" ] }, "cooperation_at_end": { @@ -2004,6 +2016,288 @@ }, "singularity_round": { "type": "integer" + }, + "archon_health_end_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "parts_end_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "parts_worth_end": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "parts_collected_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "parts_income_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "parts_spent_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "scouts_built": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "guards_built": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "vipers_built": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "turrets_built": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "turret_packs": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "robots_turned": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "neutrals_activated": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "neutral_archons_activated": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "dens_destroyed": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "den_damage_dealt": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "zombie_damage_dealt": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "zombie_damage_taken": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "enemy_damage_dealt": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "enemy_damage_taken": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "infections_suffered": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "infections_inflicted": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "viper_infection_damage": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "rubble_cleared_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "rubble_created_tenths": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "squares_opened": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "basic_signals": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "message_signals": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "archon_parts_walks": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "archons_alive_at_2000": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + "dens_per_side": { + "type": "integer" + }, + "dens_on_map": { + "type": "integer" + }, + "parts_on_map_start": { + "type": "integer" + }, + "parts_squares_start": { + "type": "integer" + }, + "rubble_mean_tenths": { + "type": "integer" + }, + "impassable_squares_start": { + "type": "integer" + }, + "impassable_squares_end": { + "type": "integer" + }, + "neutrals_on_map_start": { + "type": "integer" + }, + "zombies_spawned": { + "type": "integer" + }, + "zombies_alive_end": { + "type": "integer" + }, + "zombies_killed": { + "type": "integer" + }, + "outbreak_level_end": { + "type": "integer" + }, + "schedule_rounds": { + "type": "integer" + }, + "tiebreak_round": { + "type": "integer" } } } @@ -2103,7 +2397,7 @@ "id": "awu", "name": "awu", "type": "player", - "description": "the strong published baseline of whichever year the variant selects — distilled-awubot on bc26, Bowl of Chowder on bc20, California Roll on bc21, Gone Sharkin' on bc24, SPAARK on bc25, lemonade on bc23, wololo on bc22", + "description": "the strong published baseline of whichever year the variant selects — distilled-awubot on bc26, Bowl of Chowder on bc20, California Roll on bc21, Gone Sharkin' on bc24, SPAARK on bc25, lemonade on bc23, wololo on bc22, bulwark on bc16", "image": "{{PLAYER_IMAGE}}", "run": [ "/bin/battlecode-player" @@ -2126,7 +2420,7 @@ "id": "scaffold", "name": "scaffold", "type": "player", - "description": "the weak scaffold baseline of whichever year the variant selects — the ported example bot (examplefuncsplayer on bc20, examplefuncsplayer21 on bc21), examplefuncsplayer24 on bc24, examplefuncsplayer25 on bc25, examplefuncsplayer23 on bc23, examplefuncsplayer22 on bc22", + "description": "the weak scaffold baseline of whichever year the variant selects — the ported example bot (examplefuncsplayer on bc20, examplefuncsplayer21 on bc21), examplefuncsplayer24 on bc24, examplefuncsplayer25 on bc25, examplefuncsplayer23 on bc23, examplefuncsplayer22 on bc22, greenhorn on bc16", "image": "{{PLAYER_IMAGE}}", "run": [ "/bin/battlecode-player" @@ -2335,6 +2629,33 @@ } ] } + }, + { + "id": "bc16", + "name": "Battlecode 2016 — Zombie Invasion (2 seats)", + "description": "Best of three on the mixed pool, three thousand rounds each. Archons are the only thing that matters: lose your last one and you lose on the spot. They build soldiers, guards, vipers, turrets and scouts out of parts they collect by walking over them, and they repair one wounded robot a turn for free. Zombie dens spawn escalating waves on a schedule both sides can read from round zero, and every three hundred rounds the zombies get stronger. Anything a zombie or a viper bites is infected, and anything that dies while infected stands back up on the horde's side — so a soldier you lose in their half is a zombie hunting them. Anything that dies uninfected leaves a wall of rubble where it fell. At round 2999 the side with more archons wins.", + "game_config": { + "year": "bc16", + "pool": "mixed", + "gamesPerMatch": 3, + "seed": 0, + "maxRounds": 3000, + "num_agents": 2, + "attempt1Ms": 20000, + "retryMs": 12000, + "doctrineBudgetMs": 45000, + "perGameBudgetSeconds": 120, + "matchBudgetSeconds": 360, + "connectTimeoutMs": 25000, + "players": [ + { + "name": "Clan Ash" + }, + { + "name": "Clan Basil" + } + ] + } } ], "certification": { diff --git a/src/battlecode/results.nim b/src/battlecode/results.nim index f0d0932..3121347 100644 --- a/src/battlecode/results.nim +++ b/src/battlecode/results.nim @@ -212,6 +212,36 @@ const Bc22GameKeys* = [ ## integer), `anomalies_scheduled`, `vortexes_scheduled` and ## `singularity_round` are the eight scalars. +const Bc16GameKeys* = [ + "archon_health_end_tenths", "parts_end_tenths", "parts_worth_end", + "parts_collected_tenths", "parts_income_tenths", "parts_spent_tenths", + "scouts_built", "guards_built", "vipers_built", "turrets_built", + "turret_packs", "robots_turned", "neutrals_activated", + "neutral_archons_activated", "dens_destroyed", "den_damage_dealt", + "zombie_damage_dealt", "zombie_damage_taken", "enemy_damage_dealt", + "enemy_damage_taken", "infections_suffered", "infections_inflicted", + "viper_infection_damage", "rubble_cleared_tenths", "rubble_created_tenths", + "squares_opened", "basic_signals", "message_signals", "archon_parts_walks", + "archons_alive_at_2000", + "dens_per_side", "dens_on_map", "parts_on_map_start", "parts_squares_start", + "rubble_mean_tenths", "impassable_squares_start", "impassable_squares_end", + "neutrals_on_map_start", "zombies_spawned", "zombies_alive_end", + "zombies_killed", "outbreak_level_end", "schedule_rounds", "tiebreak_round" +] + ## bc16's own optional siblings. It REUSES TEN keys that already exist with + ## the same meaning and the same type rather than duplicating them, and those + ## ten are deliberately NOT in this list: `units_built`, `damage_dealt`, + ## `robots_alive` and `robots_lost` (bc20/bc23/bc24/bc25), and + ## `archons_start`, `archons_end`, `archons_lost`, `archons_per_side`, + ## `soldiers_built`, `repairs` and `hp_repaired` (bc22 -- 2016 and 2022 are + ## the two archon years and mean exactly the same thing by all of them). + ## `dens_per_side`, `dens_on_map`, `parts_on_map_start`, + ## `parts_squares_start`, `rubble_mean_tenths` (IN TENTHS, so the document + ## carries an integer), `impassable_squares_start`, `impassable_squares_end`, + ## `neutrals_on_map_start`, `zombies_spawned`, `zombies_alive_end`, + ## `zombies_killed`, `outbreak_level_end`, `schedule_rounds` and + ## `tiebreak_round` are the fourteen scalars. + const EndReasons* = [ "kings_destroyed", "cats_cleared", "round_limit", "abandoned", "hq_destroyed", "quantity", "quality", "broadcasts", "highest_id", @@ -223,7 +253,8 @@ const EndReasons* = [ "conquest", "more_sky_islands", "more_reality_anchors", "more_elixir_net_worth", "more_mana_net_worth", "more_adamantium_net_worth", - "more_archons", "more_gold_net_worth", "more_lead_net_worth" + "more_archons", "more_gold_net_worth", "more_lead_net_worth", + "archons_destroyed", "more_archon_health", "more_parts_net_worth" ] ## The union of all SIX years' `DominationFactor` renderings plus our own ## wall-clock `abandoned`. bc24's `MORE_FLAGS_PICKED` and `RESIGNATION` are @@ -237,6 +268,16 @@ const EndReasons* = [ ## JSON doctrine) and bc23 contributes no `destroy_all_units`, because THERE ## IS NO ELIMINATION CONDITION in the 2023 rule set at all ## (docs/RULES-BC23.md section Divergences item 7). + ## + ## bc16 adds EXACTLY THREE -- `archons_destroyed` (`DESTROYED`), + ## `more_archon_health` (`OWNED`) and `more_parts_net_worth` + ## (`BARELY_BEAT`) -- and REUSES `more_archons` (bc22's `PWNED`, the same + ## words), `highest_id` (bc20's `WON_BY_DUBIOUS_REASONS`) and `abandoned`. + ## `annihilated` is deliberately NOT reused for `DESTROYED` even though the + ## semantics match bc21's and bc22's, because docs/RULES-BC2x.md already + ## documents `annihilated` as THOSE years' factor and a bc16 replay must + ## trace to `DESTROYED`. `zombified` and `cleansed` are NOT added: both are + ## reachable only on armageddon maps, which are out of scope (bc16 V4). const ResultsKeys* = [ "names", "aliases", "scores", "wins", "points", "games", "seed", "year", diff --git a/tools/ci/policies.json b/tools/ci/policies.json index 535e8c4..54aa933 100644 --- a/tools/ci/policies.json +++ b/tools/ci/policies.json @@ -257,5 +257,42 @@ "PLAYER_SCRIPTED": "examplefuncsplayer22", "PLAYER_POLICY_LABEL": "examplefuncsplayer22" } + }, + { + "name": "battlecode-bc16-bulwark", + "run": "/bin/battlecode-player", + "image": "cogame-battlecode-player:latest", + "env": { + "PLAYER_PROMPT": "You command a faction in Battlecode 2016 'Zombie Invasion'. Two things kill you: the enemy and the horde, and the horde escalates. Zombie dens spawn on a public schedule that you can read from round 0, and every 300 rounds every new zombie gets stronger — x1.0, x1.1, x1.2, x1.3, x1.5, x1.7, x2.0, x2.3, x2.6, x3.0 — so a round-2700 BIGZOMBIE has 5000 health and 250 damage. Your doctrine: build a wall the horde breaks itself on and win on archons at round 3000. A GUARD costs 30 parts, has 145 health against a soldier's 60, deals DOUBLE damage to zombies, and blocks 4 damage off any hit above 10 — which is exactly a BIGZOMBIE's 25. A TURRET costs 130 parts and 25 turns of a frozen archon, cannot shoot anything closer than range-squared 6, and deals 13 out to range-squared 40. Set opening \"turtle\", turret_count 4-8, guard_ratio high (55-85), zombie_kiting \"never\" or \"ranged_only\", archon_spread \"huddle\" or \"spread\", rubble_clear \"aggressive\" so your home ground is full speed and your ring is closed, retreat_hp 40-70 so wounded units reach an archon's free 1-health-a-turn repair, and parts_priority \"turrets\" or \"units\". Set den_clear_round deliberately and say why: a den is 2000 health and pays 200 parts, and every den you kill deletes its share of every future wave. Set infection_policy \"quarantine\" at least: an infected unit that dies becomes an enemy zombie where it fell, and you do not want that inside your own ring. In notes, say which den you kill first and what you do if their soldiers arrive before round 400.", + "PLAYER_POLICY_LABEL": "bulwark" + } + }, + { + "name": "battlecode-bc16-pullers", + "run": "/bin/battlecode-player", + "image": "cogame-battlecode-player:latest", + "env": { + "PLAYER_PROMPT": "You command a faction in Battlecode 2016 'Zombie Invasion'. Everyone in 2016 fought the horde. The horde has exactly one targeting rule: every zombie, every turn, walks at the NEAREST PLAYER-CONTROLLED ROBOT on the map, of either team — and a SCOUT costs 25 parts, has 80 health, sees range-squared 53, IGNORES RUBBLE ENTIRELY, and cannot attack. So the cheapest weapon in this game is a scout standing on the far side of a den. Second thing nobody spent: infection. A VIPER hit infects for 20 turns at 2 damage a turn, and ANY robot that dies while infected leaves no rubble and stands back up as a zombie of its own type on the zombie team — a soldier becomes a STANDARDZOMBIE, a scout a FASTZOMBIE, an archon a BIGZOMBIE — and it then hunts whoever is nearest, which can be them. Your doctrine: set opening \"scout_zombie_pull\" or \"soldier_viper_aggro\", parts_priority \"vipers\", turret_count low (0-3) because a turret cannot chase, guard_ratio low-to-middling (10-40), zombie_kiting \"always\" or \"ranged_only\" — a soldier pays movement delay 2 and a STANDARDZOMBIE 3, so you can outrun it and shoot it from range-squared 13, but a FASTZOMBIE pays 1.4 and ignores rubble, so say what you do about those. Set archon_spread \"split\" and neutral_activation \"hunt\": neutral robots activate for FREE within range-squared 2 of an archon, some maps place neutral ARCHONS, and an extra archon is the first tiebreak at round 3000. Set infection_policy \"suicide_squad\", rubble_clear \"paths\", den_clear_round late (1500-2800), retreat_hp 20-50. In notes, say where your first two scouts stand and which of their archons you intend to be standing next to when one of your units turns.", + "PLAYER_POLICY_LABEL": "pullers" + }, + "player": "ply_bac48eb1-662e-44f8-973d-f3e016dccf5d" + }, + { + "name": "battlecode-bulwark", + "run": "/bin/battlecode-player", + "image": "cogame-battlecode-player:latest", + "env": { + "PLAYER_SCRIPTED": "bulwark", + "PLAYER_POLICY_LABEL": "bulwark" + } + }, + { + "name": "battlecode-greenhorn", + "run": "/bin/battlecode-player", + "image": "cogame-battlecode-player:latest", + "env": { + "PLAYER_SCRIPTED": "greenhorn", + "PLAYER_POLICY_LABEL": "greenhorn" + } } ] From 4c5a0dee4ea428e0608656629756b4a6ed991f9d Mon Sep 17 00:00:00 2001 From: David Bloomin Date: Wed, 9 Sep 2026 05:46:07 +0000 Subject: [PATCH 03/16] bc16: match events, the round-index arm, and two broken beat labels `match.nim`'s `collectGameEvents` gains the bc16 arms -- `zombie_wave`, `outbreak`, `den_destroyed`, `neutral_activated`, `infection`, `turned`, `tiebreak` and `unit_milestone`, plus year-tested `first_action`, `rout` and `archon_lost` -- each bounded per game as `docs/REPLAY.md` records. `winBonusFor` adds bc16 to the 200 set, because bc16's `points` can favour the loser: a one-unit margin on a rung with large totals is arbitrarily small. `years/dispatch.nim`'s `currentRound` arm returns `world.currentRound + 1`. bc16 is the only year whose world counts from -1 and whose first played round is 0, exactly as the engine's is; `replay.nim`'s year-neutral deriver indexes the recorded chain with a ROUNDS-PLAYED count, so the arm converts. Every event, trace line and viewer clock still carries the engine's 0-based number. AND A REAL DEFECT: `broadcast.nim`'s `zombie_wave` and `outbreak` beat labels read `count`, `dens` and `multiplier`, none of which are emitted -- the fields are `total`, `dens_spawning`, `outbreak_level` and `multiplier_permille`. A missing key in a `JsonNode` `{}` lookup reads back as ZERO, so the label was non-empty, passed every word-level assertion, and showed the spectator "WAVE - 0 zombies from 0 dens at x". Both labels now read the emitted fields and render the per-mille multiplier as `1.1x`. --- src/battlecode/broadcast.nim | 14 ++-- src/battlecode/match.nim | 105 ++++++++++++++++++++++++++++-- src/battlecode/years/dispatch.nim | 16 ++++- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/battlecode/broadcast.nim b/src/battlecode/broadcast.nim index 32ddf2e..6a1d62e 100644 --- a/src/battlecode/broadcast.nim +++ b/src/battlecode/broadcast.nim @@ -143,6 +143,11 @@ proc econFor(w: World, sideAslot: int): JsonNode = "dirt": w.teamInfo.dirtPlaced[t] }) +proc permille(value: int): string = + ## bc16's outbreak multiplier travels as an integer per-mille (1000, 1100, + ## … 3000) so the replay stays float-free. Rendered as `1.1`, `3.0`. + $(value div 1000) & "." & $((value mod 1000) div 100) + proc beatsFor*(doc: ReplayDoc, frameOfGameRound: proc (g, r: int): int): JsonNode = ## Every scrubber beat, with the ABSOLUTE frame it lands on. The game block ## turns each of these into a labelled, clickable ` + + @@ -3758,6 +3987,18 @@
+ +
+
+
+
+
+ +
+
+
@@ -3838,6 +4079,10 @@ or anomalies is stored in the replay -- the wasm sim re-derives every round. -->
+ +
@@ -5374,6 +5619,386 @@ })(); + + + `, ``, ` → static_replay.js`). Nothing is rewritten and **no existing id is reused for a different purpose** (the cogame-gridlock 2026-08-23 scar). | + +Also unchanged and **byte-for-byte**: **`client/chrome_common.js`** is **copied byte-for-byte** into +the bundle, and so is **`client/broadcast_core.js`**; their sha256 is asserted against the +`coworld-ctf` copies in `tests/test_viewer.nim`, and that assertion stays green because neither file +is touched. `wire_constants.js` is regenerated from the sim by `tools/gen_wire_constants.nim`, as +today. + +**Load signalling** (unchanged from the starter, restated because it is a checklist item): +`static_replay.js` sets `document.documentElement.setAttribute('data-replay-loaded', 'true')` on the +**first drawn frame** — the worker's `loaded` message after the first board frame is composited, never +on rAF timing at the call site (the chorus 2026-08-24 scar) — and the `coworld-replay` bridge posts +`ready` from a callback fired **after** that attribute is set. On any failure — fetch, JSON parse, an +unknown `game_version`, a wasm abort, or a hash mismatch that prevents rendering — it sets +**`data-replay-error=""`** on `` and shows the failure card. + +### The appended bc16 game block + +**No starter element is removed from the page.** The bc26 block's ids (`#coopchip`, `#bars`, +`#gamechips`, `#econ`, `#doctrines`) and the bc20/bc21/bc22/bc23/bc24/bc25 blocks' (`#bc20-flood` … +`#bc25-srp`, 41 ids in all) all stay exactly where they are. **What bc16 removes is nothing from the +page and everything from the screen**: like every other year block it appends +`html[data-year="bc16"] #coopchip, … #bc25-srp { display: none !important }` for those 41 ids and +`html:not([data-year="bc16"]) #bc16-… { display: none !important }` for its own **seven**, so on a +bc16 replay exactly the bc16 set plus the shared chrome is visible. The bc16 ids are all new and all +prefixed: + +- `#bc16-archons` — **the headline readout, and the year's whole story**, in the same top-centre pill + slot bc22 uses: a two-sided archon tally `ASH ▲▲ 2 — 3 ▲▲▲ BASIL` with a health pip per archon that + drains as it is shot (1000 HP each), a **green ring** on any archon that is zombie-infected and a + **violet ring** on any that is viper-infected, and it **flashes red when an archon dies**, because + that is the only event that can end the game. +- `#bc16-horde` — **the year's signature readout, and the one no other year has**: the horde clock. In + the top band beside the archon pill: zombies alive by type + (`◆12 ➤6 ⚡4 ●1`), the **next scheduled wave, its composition and how many rounds away** + (`WAVE 12+8+10+4 in 37`), the **outbreak level and multiplier** (`OUTBREAK 5 — ×1.7`), **dens + standing** (`DENS 4`) and the tiebreak countdown (`ROUND 2999 — 412 to go`). On a wave round it takes + over the strip for two seconds with what happened in plain words + (`WAVE — 34 zombies from 4 dens at ×1.7`), and on a `turned` event with + (`CLAN ASH'S ARCHON TURNS — a BIGZOMBIE at 34,19`), which is the single most watchable thing in this + year. It **keeps its wave composition and its countdown at every width**, including 360 px. +- `#bc16-econ` — per faction: parts banked (integer), income per round (`2 − 0.01 × units`, printed as + `x.x`), parts still on the map, **dens destroyed and the bounty collected**, neutrals activated (and + how many of them were ARCHONs), and **impassable squares on the map now vs at round 0** — the rubble + story, made visible. +- `#bc16-units` — per faction: the six player-type census with archons emphasised, **units still + building** shown separately (a soldier is inert for 12 turns and a viper for 30), **units infected**, + and robots lost / robots turned. +- `#bc16-doctrines` — both sheets in plain words, **dismissible**: a `#bc16-doctrines-close` button + with `aria-label="Dismiss doctrines"`, an `Escape` binding, self-dismissal on the first playback + advance (or after six seconds for a viewer who never presses play), and a `#bc16-doctrines-toggle` + chip in the scorebug that re-opens it. Its body is **capped and scrolls** (the bc23/bc25 + doctrine-card clipping finding), and it sits above the board area and **never** inside the transport + band. It carries the **submitted-vs-applied badge** the envelope pin requires. +- `#bc16-siege` — the endcard panel (below). + +Year selection is one attribute plus CSS, not a rewrite: the shared `onText` block already sets +`document.documentElement.dataset.year` from the replay header and re-runs `relayout()` on a change +(`client/replay_broadcast.html:6329`); the stylesheet extends the existing +`html:not([data-year="bc22"]) #bc22-… { display: none !important }` pattern with the bc16 pair. +**Every bc16 rule — including every beat-marker colour — is scoped to `html[data-year="bc16"]`** (the +bc21 r1-F4 fix, kept), so none of them can restyle another year's marker of the same name. The frame +hook is `window.Bc16Block.active(s)` / `.onFrame(s)`, added beside the existing six in the shared +`onText`, and the `if (!isBc20 && !isBc21 && !isBc22 && !isBc23 && !isBc24 && !isBc25)` guard becomes +`if (!isBc16 && !isBc20 && !isBc21 && !isBc22 && !isBc23 && !isBc24 && !isBc25)`. + +### The beat contract — emission, label and style, all three tested + +This is where the bc25 run failed review (r1-F26: eleven beat-kind CSS rules against two emitted +kinds), so it is specified as three obligations that **one** test asserts together against the +**committed fixture replay** (`tests/fixtures/replay-bc16.json`): + +1. **Emission.** `beatsFor` in `src/battlecode/broadcast.nim:139` is the only place a beat kind is + decided. bc16 adds `let isBc16 = doc.year == "bc16"` beside the three that are already there + (`:145-147`) and an arm for each of its event kinds. **Four names collide with other years and each + needs the year test**: `first_action` (bc22/bc23/bc25 map it to `build`; bc16 joins them), + `rout` (same three; bc16 joins), `duel` (bc22's and bc23's; bc16's carries the same field name with + a different meaning — attackers lost, not launchers — so the **label** switch gains a year test), + and **`archon_lost`**, which bc22 already emits with `gold_dropped` where bc16 carries `cause` — so + the label switch gains a year test there too. The bc16-only kinds (`zombie_wave`, `outbreak`, + `den_destroyed`, `neutral_activated`, `infection`, `turned`, `unit_milestone`, `tiebreak`) need no + discriminator, because no other year emits those event names — even though two of their beat kinds + (`build`, `end`) are spelled the same as another year's, which is exactly why the CSS scoping is + mandatory. +2. **Label.** Every emitted beat carries a spectator-readable label built in the same `case` — e.g. + `"WAVE — 34 zombies from 4 dens at ×1.7, game 2, round 1800"`, + `"Clan Ash breaks the den at 0,0 — 200 parts and 43 zombies deleted, game 1, round 912"`, + `"OUTBREAK 5 — every new zombie is 1.7× stronger from here"`, + `"Clan Basil activates a neutral ARCHON at 21,14 — it has four now"`, + `"CLAN ASH'S SOLDIER TURNS — a STANDARDZOMBIE at 34,19, and it is hunting Basil"`, + `"ARCHON DOWN — Clan Ash has 1 left, killed by a BIGZOMBIE"`, + `"ROUND 2999 — archons level at 2, Clan Basil wins on archon health 1740 to 1155"` — and it becomes + the `