diff --git a/assets/images/civilization/civilization-cities-mayan.png b/assets/images/civilization/civilization-cities-mayan.png new file mode 100644 index 0000000..a5a40fd Binary files /dev/null and b/assets/images/civilization/civilization-cities-mayan.png differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index c4edf8e..7527b2d 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -17,6 +17,7 @@ "classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 }, "cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 }, "japanese": { "key": "civilization-cities-japanese", "path": "assets/images/civilization/civilization-cities-japanese.png", "frameWidth": 128, "frameHeight": 96 }, - "steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 } + "steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 }, + "mayan": { "key": "civilization-cities-mayan", "path": "assets/images/civilization/civilization-cities-mayan.png", "frameWidth": 128, "frameHeight": 96 } } } diff --git a/data/opponents.json b/data/opponents.json index 70b27c5..7519280 100644 --- a/data/opponents.json +++ b/data/opponents.json @@ -130,7 +130,7 @@ "bronzeworking", "masonry" ], - "citySheet": "classic", + "citySheet": "mayan", "bio": "Wassssuppp everybody! I'm here for the party!!", "voice": "Batman - Killer Croc", "convo": [ @@ -172,7 +172,7 @@ "warriorcode", "thewheel" ], - "citySheet": "classic", + "citySheet": "mayan", "bio": "Can you find your way out of my maze of puzzles, games and fun!", "speech": { "intro": [ @@ -543,7 +543,7 @@ "warriorcode", "masonry" ], - "citySheet": "classic", + "citySheet": "mayan", "bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!", "voice": "Djed Spence", "convo": [ @@ -616,7 +616,7 @@ "name": "Balam", "trait": "pioneering", "startingTechs": [], - "citySheet": "classic", + "citySheet": "mayan", "bio": "Mystical Powers and a knack for games.", "speech": { "intro": [ @@ -722,7 +722,7 @@ "masonry", "alphabet" ], - "citySheet": "classic", + "citySheet": "mayan", "bio": "Aaaaaaargh! Me be playin' these games matey!", "voice": "pirate", "speech": { @@ -933,7 +933,7 @@ "ceremonialburial", "masonry" ], - "citySheet": "classic", + "citySheet": "mayan", "bio": "I haven't had this much fun since the Mezasoic era!", "speech": { "intro": [ diff --git a/data/totalannihilation-campaign.json b/data/totalannihilation-campaign.json new file mode 100644 index 0000000..cfb0087 --- /dev/null +++ b/data/totalannihilation-campaign.json @@ -0,0 +1,988 @@ +{ + "_comment": "Generated by tools/genTACampaign.js — terrain is baked, design is hand-authored.", + "missions": [ + { + "id": "m01", + "name": "Beachhead", + "theme": "grasslands", + "seed": 10461, + "playerArmy": "arm", + "playerCommander": "vance", + "enemies": [ + { + "army": "core", + "commander": "klaxon", + "aiProfile": { + "skill": 1, + "aggression": 0.25 + } + } + ], + "startResources": [ + { + "mass": 1600, + "energy": 2600 + }, + { + "mass": 600, + "energy": 900 + } + ], + "objective": { + "type": "destroyAll" + }, + "briefing": [ + { + "speaker": "ethel", + "mood": "idle", + "text": "Establish a base and remove the CORE presence. Power first — nothing moves without it." + } + ], + "victoryLine": { + "speaker": "ethel", + "mood": "happy", + "text": "Beachhead secured. The corridor inland is open." + }, + "defeatLine": { + "speaker": "klaxon", + "mood": "smug", + "text": "The landing has been thrown back into the sea." + }, + "map": { + "w": 64, + "h": 64, + "theme": "grasslands", + "rows": [ + "~~..rrrr.......rrr............................rrr.......rrrr..~~", + "~~..............rr............................rr..............~~", + "~~............................................................~~", + "~~............................................................~~", + "~~.........^^^....................................^^^.........~~", + "~~.........^^^....................................^^^.........~~", + "~~.........^^^....................................^^^.........~~", + "~~.........rrr....................................rrr.........~~", + "~~............r^^.............r^^r.............^^r............~~", + "~~~........^^^^^^............r.^^.r............^^^^^^........~~~", + "~~~r......^^^^^^.............^^^^^^.............^^^^^^......r~~~", + "~~~r....^^^^^^^.............^^^^^^^^.............^^^^^^^....r~~~", + "~~~.....^^^^^^^....rr......^^^^^^^^^^......rr....^^^^^^^.....~~~", + "~~~.....^^^^^^^...rrr.......^^^^^^^^.......rrr...^^^^^^^.....~~~", + "~~.....r^^^^^^.....rrr.......^^^^^^.......rrr.....^^^^^^r.....~~", + "~~.....r.^^^^^.....rrr........^^^^........rrr.....^^^^^.r.....~~", + "~~.....rr..........r........................r..........rr.....~~", + ".......rrr.........r..r..................r..r.........rrr.......", + "r.......r..........rrrrr................rrrrr..........r.......r", + "...................rrrrr................rrrrr...................", + ".....................rr..................rr.....................", + "....................~~~..................~~~....................", + "...................~~~~~~rr..........rr~~~~~~...................", + ".......MM......r...~~~~~~~rr........rr~~~~~~~...r......MM.......", + ".......MM.....rrr.r~~~~~~~~rr......rr~~~~~~~~r.rrr.....MM.......", + "..............rr...~~~~~~...r......r...~~~~~~...rr..............", + "....................~~~.....rr....rr.....~~~....................", + "............................rr....rr............................", + "..................MM.........rrrrrr.........MM..................", + "...MM.............MM.........rrrrrr.........MM.............MM...", + "...MM.........................rrrr.........................MM...", + "..................r..........^^^^^^..........r..................", + "r..................r....^^^^^^^^^^^^^^^^....r..................r", + "r.................rr...^^^^^^^^^^^^^^^^^^...rr.................r", + ".......................^^^^^^^^^^^^^^^^^^.......................", + "....................rrr.^^^^^^^^^^^^^^^^.rrr....................", + "...............MM...rrr.^^^^^^^^^^^^^^^^.rrr...MM...............", + "...............MM...rrr.^^^^^^^^^^^^^^^^.rrr...MM...............", + ".....................rrr....^^^^^^^^....rrr.....................", + "rr............r.......r.....rrrrrrrr.....r.......r............rr", + "r.....~~~~.~~~...............rrrrrr...............~~~.~~~~.....r", + "r.~~~~~~~~~~~~...............rrrrrr...............~~~~~~~~~~~~.r", + "~~~~~~~~~~~~~~..............rrrrrrrr..............~~~~~~~~~~~~~~", + "~~~~~~~~~~~~~~.............rr......rr.............~~~~~~~~~~~~~~", + "~~~~~~~~~~~~~~...........rrr........rrr...........~~~~~~~~~~~~~~", + "~~~~~~~~~~~~~~..........rrrr........rrrr..........~~~~~~~~~~~~~~", + "~~~~~~~~~~~~~~...........rrr........rrr...........~~~~~~~~~~~~~~", + "~~~~~~~~~~~~~~r..................................r~~~~~~~~~~~~~~", + "...~~~~~~~~~~~....................................~~~~~~~~~~~...", + "......~~~~~~~~....................................~~~~~~~~......", + ".......~~~~~~~....................................~~~~~~~.......", + ".......~~~~~~~....................................~~~~~~~.......", + "........~~~~~~....................................~~~~~~........", + "........~~~~~~....................................~~~~~~........", + "..rr....~~~~~~....................................~~~~~~....rr..", + "..rr....~~~~~~......rr..rrr..........rrr..rr......~~~~~~....rr..", + "..rr....~~~~~~......rr..rrr..........rrr..rr......~~~~~~....rr..", + "........~~~~~~....................................~~~~~~........", + "........~~~~~~....................................~~~~~~........", + "........~~~~~~r..................................r~~~~~~........", + "........~~~~~~r..................................r~~~~~~........", + "........~~~~~~r..................................r~~~~~~........", + "........~~~~~~rrrr...~....................~...rrrr~~~~~~........", + "..rr...r~~~~~~..r...~~~.....r......r.....~~~...r..~~~~~~r...rr.." + ], + "starts": [ + { + "army": 0, + "x": 10, + "y": 32 + }, + { + "army": 1, + "x": 53, + "y": 32 + } + ], + "buildings": [] + } + }, + { + "id": "m02", + "name": "Metal Fields", + "theme": "grasslands", + "seed": 20773, + "playerArmy": "arm", + "playerCommander": "vance", + "enemies": [ + { + "army": "core", + "commander": "klaxon", + "aiProfile": { + "skill": 2, + "aggression": 0.4 + } + } + ], + "startResources": [ + { + "mass": 1300, + "energy": 2200 + }, + { + "mass": 900, + "energy": 1400 + } + ], + "objective": { + "type": "destroyAll" + }, + "briefing": [ + { + "speaker": "ethel", + "mood": "idle", + "text": "Metal patches double a generator's yield. Take them before CORE does." + } + ], + "victoryLine": { + "speaker": "ethel", + "mood": "happy", + "text": "The patches are ours. Production doubles from here." + }, + "defeatLine": { + "speaker": "klaxon", + "mood": "smug", + "text": "CORE holds the metal. We cannot match their output." + }, + "map": { + "w": 64, + "h": 64, + "theme": "grasslands", + "rows": [ + ".............r............^^^^^^^^^^^^............r.............", + ".......rr.................^^^^^^^^^^^^.................rr.......", + "....rrrrr.................^^^^^^^^^^^^.................rrrrr....", + "...rrrrr...................^^^^^^^^^^...................rrrrr...", + "....r......................................................r....", + "................................................................", + "...r....r..~~......................................~~..r....r...", + "..rrr..~~~~~~~....r..........................r....~~~~~~~..rrr..", + "r..r...~~~~~~~....r..........................r....~~~~~~~...r..r", + "....r.~~~~~~~~....rrrr.......rrrrrr.......rrrr....~~~~~~~~.r....", + "...~~~~~~~~~~~....rrrr.......~~~~~~.......rrrr....~~~~~~~~~~~...", + "..~~~~~~~~~~..r~~..r........~~~~~~~~........r..~~r..~~~~~~~~~~..", + "..~~~~~~~~~r..r~~~.........~~~~~~~~~~.........~~~r..r~~~~~~~~~..", + "..~~~~~~~~~...r~~~..........~~~~~~~~..........~~~r...~~~~~~~~~..", + "...~~~~~~~.....~~~............~~~~............~~~.....~~~~~~~...", + "....~~r.........~~............................~~.........r~~....", + ".....r....................................................r.....", + "................................................................", + ".......................rr..............rr.......................", + "..........r..rr........rr..............rr........rr..r..........", + "....rrr...^^^^r..........r............r..........r^^^^...rrr....", + "....rr.^^^^^^^^.........~~............~~.........^^^^^^^^.rr....", + "...rrr.^^^^^^^^r.......~~~~..........~~~~.......r^^^^^^^^.rrr...", + "....rrrr^^^^^^^r.......~~~~..........~~~~.......r^^^^^^^rrrr....", + ".....rr....^...........~~~~..........~~~~...........^....rr.....", + ".....rr.................~~............~~.................rr.....", + "........................~~............~~........................", + "................................................................", + "........MM............................................MM........", + ".rr.....MM............................................MM.....rr.", + "^rr..........................................................rr^", + "^rr..........................................................rr^", + "^r............................................................r^", + "^..............................................................^", + "^.....................rr................rr.....................^", + "^.....................rr................rr.....................^", + "..............MM......rr................rr......MM..............", + "..............MM......rr................rr......MM..............", + "...................MM......................MM...................", + "...................MM......................MM...................", + "...........~~rr.rr............................rr.rr~~...........", + "...........rrrr.rr............................rr.rrrr...........", + "..........rrrrMM................................MMrrrr..........", + ".........rrr..MM................................MM..rrr.........", + "..........r..........................................r..........", + "........................rrrr........rrrr........................", + "........................rrrr........rrrr........................", + ".......................rrrrr........rrrrr.......................", + ".......................rrrrr........rrrrr.......................", + "........................rrrr........rrrr........................", + "...........................r........r...........................", + "...........................r........r...........................", + "........................rrrr........rrrr........................", + "r.......................rrrr........rrrr.......................r", + "......rr...............rr..rr......rr..rr...............rr......", + ".....rrr...............rr..rr......rr..rr...............rrr.....", + ".....rrr................................................rrr.....", + "......rr................................................rr......", + "r..............................................................r", + "................................................................", + ".........rrr..........rr....rr....rr....rr..........rrr.........", + ".........rrr.........rrr....rr....rr....rrr.........rrr.........", + "r........rrr.........rrr................rrr.........rrr........r", + ".r.r.r.rrrr~~.......rr....................rr.......~~rrrr.r.r.r." + ], + "starts": [ + { + "army": 0, + "x": 10, + "y": 32 + }, + { + "army": 1, + "x": 53, + "y": 32 + } + ], + "buildings": [ + { + "army": 1, + "type": "energygen", + "tx": 56, + "ty": 30 + } + ] + } + }, + { + "id": "m03", + "name": "Cold Open", + "theme": "snowfields", + "seed": 31287, + "playerArmy": "arm", + "playerCommander": "vance", + "enemies": [ + { + "army": "core", + "commander": "cybro", + "aiProfile": { + "skill": 2, + "aggression": 0.6 + } + } + ], + "startResources": [ + { + "mass": 1200, + "energy": 2000 + }, + { + "mass": 1100, + "energy": 1800 + } + ], + "objective": { + "type": "destroyAll" + }, + "briefing": [ + { + "speaker": "ethel", + "mood": "idle", + "text": "Cy-Bro opens with infantry. Armour answers infantry — get a Vehicle Plant up early." + } + ], + "victoryLine": { + "speaker": "ethel", + "mood": "happy", + "text": "Cy-Bro's foothold is slag." + }, + "defeatLine": { + "speaker": "cybro", + "mood": "smug", + "text": "Overrun before the plant finished." + }, + "map": { + "w": 64, + "h": 64, + "theme": "snowfields", + "rows": [ + "...rr.....~~~~........r..........rrrrr...^^^^^^^^^^^^^^^^^^^^^^^", + "...........~~~....................rrr....^^^^^^^^^^^^^^^^^^^^^^^", + "..............................~~.~~........^^^^^^^^^r...........", + "rr.........................~~...................................", + "rr.....................................................rr.......", + ".......................................................rr.......", + "..............................MM..................^^^^^^^^^^....", + "..............................MM................^^^^^^^^^^^^^...", + "................................................^^^^^^^^^^^^^^..", + "r..rr............................................^^^^^^^^^^^^^r.", + "rr.rr............................................r^^^^^^^^^^^rr.", + "rr.................................................^^^^^^^^^rr..", + "...............rr...................................^^^^^^^^...r", + "...............rr............MM.....................^^^^^^^.....", + ".............................MM..MM..................^^^^^r....r", + ".................................MM.............................", + "...~~~..........................................................", + "..~~~~~r...........r.......................................r..rr", + "r~~~~~~~...~~~~~~.rrr.......rrrr....................rr....rrrrrr", + "~~~~~~~~~~~~~~~~~~rr........rrrr..............r.....rr....rrrrrr", + "~~~~~~~~~~~~~~~~~~~..........................rrr................", + "~~~~~~~~~~~~~~~~~~~..........................rrr................", + "~~~~~~~~~~~~~~~~~~~..........................rrrrrrrr......rr..^", + "~~~~~~~~~~~~~~~~~...........................rr.rrrrrr......rr..^", + "~~~~~~~........r.................MM.......^^^^^...rr........rr^^", + "~~~~~~~........r.................MM......^^^^^^^..rr.......rrrr^", + "~~~~~~~.................................^^^^^^^^^.rr.......rrrr^", + "~~~~~~..................................^^^^^^^^^^............rr", + "~~~~~~..........rr.....................^^^^^^^^^^^^............r", + ".~~~~...........rr....................^^^^^^^^^^^^^^...........r", + ".....................~~..rrr..........^^^^^^^^^^^^^^^..........r", + ".....................~~..rrr..........^^^^^^^^^^^^^^^^.........r", + ".....................~~..rrr..........^^^^^^^^^^^^^^^^.........r", + ".....................~~..rrr..........^^^^^^^^^^^^^^^..........r", + ".~~~~...........rr....................^^^^^^^^^^^^^^...........r", + "~~~~~~..........rr.....................^^^^^^^^^^^^............r", + "~~~~~~..................................^^^^^^^^^^............rr", + "~~~~~~~.................................^^^^^^^^^.rr.......rrrr^", + "~~~~~~~........r.................MM......^^^^^^^..rr.......rrrr^", + "~~~~~~~........r.................MM.......^^^^^...rr........rr^^", + "~~~~~~~~~~~~~~~~~...........................rr.rrrrrr......rr..^", + "~~~~~~~~~~~~~~~~~~~..........................rrrrrrrr......rr..^", + "~~~~~~~~~~~~~~~~~~~..........................rrr................", + "~~~~~~~~~~~~~~~~~~~..........................rrr................", + "~~~~~~~~~~~~~~~~~~rr........rrrr..............r.....rr....rrrrrr", + "r~~~~~~~...~~~~~~.rrr.......rrrr....................rr....rrrrrr", + "..~~~~~r...........r.......................................r..rr", + "...~~~..........................................................", + ".................................MM.............................", + ".............................MM..MM..................^^^^^r....r", + "...............rr............MM.....................^^^^^^^.....", + "...............rr...................................^^^^^^^^...r", + "rr.................................................^^^^^^^^^rr..", + "rr.rr............................................r^^^^^^^^^^^rr.", + "r..rr............................................^^^^^^^^^^^^^r.", + "................................................^^^^^^^^^^^^^^..", + "..............................MM................^^^^^^^^^^^^^...", + "..............................MM..................^^^^^^^^^^....", + ".......................................................rr.......", + "rr.....................................................rr.......", + "rr.........................~~...................................", + "..............................~~.~~........^^^^^^^^^r...........", + "...........~~~....................rrr....^^^^^^^^^^^^^^^^^^^^^^^", + "...rr.....~~~~........r..........rrrrr...^^^^^^^^^^^^^^^^^^^^^^^" + ], + "starts": [ + { + "army": 0, + "x": 32, + "y": 10 + }, + { + "army": 1, + "x": 32, + "y": 53 + } + ], + "buildings": [ + { + "army": 0, + "type": "energygen", + "tx": 35, + "ty": 8 + }, + { + "army": 1, + "type": "barracks", + "tx": 36, + "ty": 56 + } + ] + } + }, + { + "id": "m04", + "name": "The Long Ridge", + "theme": "grasslands", + "seed": 44377, + "playerArmy": "arm", + "playerCommander": "vance", + "enemies": [ + { + "army": "core", + "commander": "klaxon", + "aiProfile": { + "skill": 3, + "aggression": 0.5 + } + } + ], + "startResources": [ + { + "mass": 2200, + "energy": 3600 + }, + { + "mass": 1300, + "energy": 2200 + } + ], + "objective": { + "type": "destroyAll" + }, + "briefing": [ + { + "speaker": "ethel", + "mood": "idle", + "text": "A fair fight on a wide map. Expand hard — whoever out-earns the other wins the third engagement." + } + ], + "victoryLine": { + "speaker": "ethel", + "mood": "happy", + "text": "The ridge is held." + }, + "defeatLine": { + "speaker": "klaxon", + "mood": "smug", + "text": "KLAXON out-produced us and it showed." + }, + "map": { + "w": 96, + "h": 96, + "theme": "grasslands", + "rows": [ + "~~~~~~~~~~~~~~~....~~~.....~~~~~~~~~..r...........rr.~~~~~~...r......r...r..............r.......", + "~~~~~~~~r~~~~~~.....~~.....~~~~~~~~~..................~~~~......................................", + "~~~........~~~rr............~~~~~~~~..................~~~~................................rrrrrr", + "~~~...........r.............~~~~~~~......................................................rrrrr..", + "~~~.........................~~~~~.................rr......................................r.....", + "~~~.......MM................~~~~r................rrrr..........................................^", + "~~~...MM..MM................~~~~r.................rr..........................rr................", + "~~~...MM..................r~~~~~..................rr..........................rrrrr.............", + ".~~....................~~~~~.r...........rr....................................rrrrr............", + ".~~....................r~~~..............rr......................r^^r.....rrr~~rrrrr............", + "~~......................................rrr......................^^^^.....rrr~~................^", + "~~~MM..................................r.........................^^^^........~~................^", + "~~.MM..................................rr........................^^^^........r~................^", + ".......................................rrr.................r..................~r................", + "....................MM................rrrr.................rrrr..............~~~rrr.............", + "....................MM................rr....................rrrr...........~~~~~~rr.............", + "..................................rrr...........................rrr........~~~~~~~..............", + "..................................rrr............................rr.......~~~~~~~......rrrr.r...", + "..................................rrr......................................~~~~~.......rrrrrrr..", + "........~~~~~~.....................r........~~~~~~~~r..........r..~~....................rrrrr...", + ".......~~~~~~~~...........rrr...^^^rr......~~~~~~~~~~~~..~~~~~~~~~~~~~~~~.......................", + "^......~~~~~~~~~..........rrrrr^^^^rr.....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~......................", + "^^......~~~~~~~~...........rr..^^^^r....rr~~~~~~~~~~~......~~~~~~~~~~~~~~.......................", + "^^r.....~~~~~~~~~..............r^^^.....rr.~~~~~~~~..........~~~~~~~~~~~r.......................", + "^^^......~~~...~~...rr...........^......rrr.~~.................~~~~~~~..........................", + "^^^................rrr..................rrrr......................~~............................", + "^^^.............................................................................................", + "^^^r............................................................................................", + "^^^.............................................................................................", + "^^^............................................................................r...............r", + "^^^..........................................................................^^^rr..............", + "^^r....................^^.................................................r^^^^^^^^.............", + "^^.................^^^^^^^.................^^^^^^^^r.....................^^^^^^^^^^^^...........", + "^^.................^^^^^^^................^^^^^^^^^^^....................^^^^^^^^^^^^^..........", + "^^...rr............^^^^^^^................^^^^^^^^^^^....................^^^^^^^^^^^^r..........", + "^.rrrrrr.............r.^^^...rr............^^^^^^.........................^^^^^^^^^^............", + "..rrrrrr................^....rr...........................................^^^^^^^^^............r", + "...rrrr....................................................................^^^^^^^..............", + "............................................................................^^^^................", + "................................................................................rr..............", + ".........rr.................................................rr.................rrr..............", + ".........r~~~~.....r~~......................................rr.................rr...............", + ".....~...~~~~~~~..r~~~~.....................................rrr................rrr.....~~~~~~...", + "....~~~~~~~~~~~~~.~~~~~.........................r....~~~~~~~~~~~~...............rrr...~~~~~~~~~.", + "....~~~~~~~~~~~~~~~~~~~~.....................rrrrr.~~~~~~~~~~~~~~~~~............rrrrrr~~~~~~~~~~", + "...r~~~~~~~~~~~~~~~~~~~~....................rrrrrrr~~~~~~~~~~~~~~~~~............rrrr..~~~~~~~~~~", + "...r~~~~~~~~~~~~~~~~~~~~...................rr..rrrr~~~~~~~~~~~r.................rrr....~~~~~~~~.", + "...r~~~~~~~~~~~~~~~~~~~~.............r....rr....r...~~~~~~~~rrr..........r.............~~~~~~r..", + "..r~~~~~~.............r..........rrr~~~~~~~~...r....rr....r.............~~~~~~~~~~~~~~~~~~~~r...", + ".~~~~~~~~....rrr.................r~~~~~~~~~~~rrrr..rr...................~~~~~~~~~~~~~~~~~~~~r...", + "~~~~~~~~~~..rrrr............~~~~~~~~~~~~~~~~~rrrrrrr....................~~~~~~~~~~~~~~~~~~~~r...", + "~~~~~~~~~~rrrrrr............~~~~~~~~~~~~~~~~~.rrrrr.....................~~~~~~~~~~~~~~~~~~~~....", + ".~~~~~~~~~...rrr...............~~~~~~~~~~~~....r.........................~~~~~.~~~~~~~~~~~~~....", + "...~~~~~~.....rrr................rrr.....................................~~~~r..~~~~~~~...~.....", + "...............rr.................rr......................................~~r.....~~~~r.........", + "..............rrr.................rr.................................................rr.........", + "..............rr................................................................................", + "................^^^^............................................................................", + "..............^^^^^^^....................................................................rrrr...", + "r............^^^^^^^^^...........................................rr....^................rrrrrr..", + "............^^^^^^^^^^.........................^^^^^^............rr...^^^.r.............rrrrrr.^", + "..........r^^^^^^^^^^^^....................^^^^^^^^^^^................^^^^^^^............rr...^^", + "..........^^^^^^^^^^^^^....................^^^^^^^^^^^................^^^^^^^.................^^", + "...........^^^^^^^^^^^^.....................r^^^^^^^^.................^^^^^^^.................^^", + ".............^^^^^^^^r.................................................^^....................r^^", + "..............rr^^^..........................................................................^^^", + "r...............r............................................................................^^^", + ".............................................................................................^^^", + "............................................................................................r^^^", + ".............................................................................................^^^", + "............................~~......................rrrr..................rrr................^^^", + "..........................~~~~~~~.................~~.rrr......^...........rr...~~...~~~......^^^", + ".......................r~~~~~~~~~~~..........~~~~~~~~.rr.....^^^r..............~~~~~~~~~.....r^^", + ".......................~~~~~~~~~~~~~~......~~~~~~~~~~~rr....r^^^^..rr...........~~~~~~~~......^^", + "......................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.....rr^^^^rrrrr..........~~~~~~~~~......^", + ".......................~~~~~~~~~~~~~~~~..~~~~~~~~~~~~......rr^^^...rrr...........~~~~~~~~.......", + "...rrrrr....................~~..r..........r~~~~~~~~........r.....................~~~~~~........", + "..rrrrrrr.......~~~~~......................................rrr..................................", + "...r.rrrr......~~~~~~~.......rr............................rrr..................................", + "..............~~~~~~~........rrr...........................rrr..................................", + ".............rr~~~~~~...........rrrr....................rr................MM....................", + ".............rrr~~~..............rrrr.................rrrr................MM....................", + "................r~..................r.................rrr.......................................", + "^................~r........^^^^........................rr..................................MM.~~", + "^................~~........^^^^.........................r..................................MM~~~", + "^................~~rrr.....^^^^......................rrr......................................~~", + "............rrrrr~~rrr.....r^^r......................rr..............~~~r....................~~.", + "............rrrrr....................................rr...........r.~~~~~....................~~.", + ".............rrrrr..........................rr..................~~~~~r..................MM...~~~", + "................rr..........................rr.................r~~~~................MM..MM...~~~", + "^..........................................rrrr................r~~~~................MM.......~~~", + ".....r......................................rr.................~~~~~.........................~~~", + "..rrrrr......................................................~~~~~~~.............r...........~~~", + "rrrrrr................................~~~~..................~~~~~~~~............rr~~~........~~~", + "......................................~~~~..................~~~~~~~~~.....~~.....~~~~~~r~~~~~~~~", + ".......r..............r...r......r...~~~~~~.rr...........r..~~~~~~~~~.....~~~....~~~~~~~~~~~~~~~" + ], + "starts": [ + { + "army": 0, + "x": 10, + "y": 10 + }, + { + "army": 1, + "x": 85, + "y": 85 + } + ], + "buildings": [ + { + "army": 0, + "type": "energygen", + "tx": 13, + "ty": 8 + }, + { + "army": 0, + "type": "energygen", + "tx": 7, + "ty": 8 + } + ] + } + }, + { + "id": "m05", + "name": "Tropic Vice", + "theme": "tropics", + "seed": 55930, + "playerArmy": "arm", + "playerCommander": "vance", + "enemies": [ + { + "army": "core", + "commander": "cybro", + "aiProfile": { + "skill": 4, + "aggression": 0.7 + } + } + ], + "startResources": [ + { + "mass": 2600, + "energy": 4200 + }, + { + "mass": 1500, + "energy": 2600 + } + ], + "objective": { + "type": "destroyAll" + }, + "briefing": [ + { + "speaker": "ethel", + "mood": "idle", + "text": "Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields." + } + ], + "victoryLine": { + "speaker": "ethel", + "mood": "happy", + "text": "Cy-Bro is scrap on the sand." + }, + "defeatLine": { + "speaker": "cybro", + "mood": "smug", + "text": "The push came faster than the plant did." + }, + "map": { + "w": 96, + "h": 96, + "theme": "tropics", + "rows": [ + "~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~", + "~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~", + "~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~", + "~.....................r........~~~~..........................~~~~........r.....................~", + "...............................~~~~~........................~~~~~...............................", + ".......................r.......~~~~~........................~~~~~.......r.......................", + "......................rr.......~~~~~........................~~~~~.......rr......................", + "......................rr.......~~~~~........................~~~~~.......rr......................", + "....................rrrr.......~~~~~........................~~~~~.......rrrr....................", + "...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............", + "...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............", + "..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............", + ".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............", + ".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............", + "................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................", + ".................r..............rr..~~~r...r........r...r~~~..rr..............r.................", + "...............................................rr...............................................", + "r..r...........................................rr...........................................r..r", + "..rrr..rr.^..........................................................................^.rr..rrr..", + "~~.r...rrrr^.................r....................................r.................^rrrr...r.~~", + "~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~", + "~~~..rr..r^^^^....................................................................^^^^r..rr..~~~", + "~~~......^^^^^....................................................................^^^^^......~~~", + "~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~", + "........^^^^^^^^^..............................................................^^^^^^^^^........", + "........^^^^^^^^^..............................................................^^^^^^^^^........", + ".......r^^^^r.^^^..............................................................^^^.r^^^^r.......", + ".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......", + ".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......", + "........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........", + "^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^", + "^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^", + "^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^", + "^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^", + "^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^", + "^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^", + "^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^", + "^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^", + "^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^", + "..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..", + "....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....", + "...............rrr...............rr........rr......rr........rr...............rrr...............", + "................................................................................................", + "................................................................................................", + "................................................................................................", + "................................................................................................", + ".............................rrr................................rrr.............................", + "...................MM........rrr................................rrr........MM...................", + "...................MM.........rrr..............................rrr.........MM...................", + "................................r..............................r................................", + "................................................................................................", + "................................................................................................", + ".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....", + ".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....", + ".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................", + "......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................", + "........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........", + "........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........", + "r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r", + ".........rrr............~~............................................~~............rrr.........", + "..........r..........................................................................r..........", + ".........~~~~~....................................................................~~~~~.........", + "........~~~~~~....................................................................~~~~~~........", + "........~~~~~~....r..........................................................r....~~~~~~........", + "^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^", + "^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^", + "^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^", + "^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^", + "^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^", + "^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^", + "^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^", + "^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^", + "^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^", + "^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^", + "^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^", + "..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..", + "..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..", + "..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..", + "..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..", + "..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..", + "..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..", + "^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^", + "^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^", + "^^r.......................................^^^^^^^^^^^^.......................................r^^", + "^^^.......................................^^^^^^^^^^^^.......................................^^^", + "^^^.........................................^......^.........................................^^^", + "^^^^.........................^^^^^^^^^^^................^^^^^^^^^^^.........................^^^^", + "^^^^........................^^^^^^^^^^^^^..............^^^^^^^^^^^^^........................^^^^", + "^^^^^.......................^^^^^^^^^^^^^..............^^^^^^^^^^^^^.......................^^^^^", + "^^^^^.......................^^^^^^^^^^^^^.....r..r.....^^^^^^^^^^^^^.......................^^^^^", + "^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^", + "^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^", + "^^r...............................r..........rrrrrr..........r...............................r^^", + ".r.....................r................................................r.....................r.", + ".............rr.......rr~~............................................~~rr.......rr.............", + "r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r" + ], + "starts": [ + { + "army": 0, + "x": 10, + "y": 48 + }, + { + "army": 1, + "x": 85, + "y": 48 + } + ], + "buildings": [ + { + "army": 0, + "type": "energygen", + "tx": 13, + "ty": 46 + }, + { + "army": 0, + "type": "energygen", + "tx": 7, + "ty": 46 + }, + { + "army": 0, + "type": "barracks", + "tx": 14, + "ty": 51 + }, + { + "army": 1, + "type": "energygen", + "tx": 88, + "ty": 50 + } + ] + } + }, + { + "id": "m06", + "name": "Annihilation", + "theme": "snowfields", + "seed": 63541, + "playerArmy": "arm", + "playerCommander": "vance", + "enemies": [ + { + "army": "core", + "commander": "klaxon", + "aiProfile": { + "skill": 5, + "aggression": 0.85 + } + } + ], + "startResources": [ + { + "mass": 3200, + "energy": 5200 + }, + { + "mass": 1800, + "energy": 3000 + } + ], + "objective": { + "type": "destroyAll" + }, + "briefing": [ + { + "speaker": "ethel", + "mood": "idle", + "text": "KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it." + } + ], + "victoryLine": { + "speaker": "ethel", + "mood": "happy", + "text": "Total annihilation. There is nothing left to fight." + }, + "defeatLine": { + "speaker": "klaxon", + "mood": "smug", + "text": "KLAXON was simply better prepared." + }, + "map": { + "w": 96, + "h": 96, + "theme": "snowfields", + "rows": [ + "................^^^...^^^^^.............^^^^^^.......r...rr....r.........r....r...........rrr...", + "..............MM.^^..r^^^^^..............^^^^^............................................rrr...", + "..............MM......^^^^^^...............rr..............................................rr...", + "......................^^^^^^..............r...............................................rrr...", + "....................................................................rrr...................rrr...", + "........................rr..........................................rrrr.....................r..", + "........................rr.........................................^..rr.......rr............rr.", + "................................................................r^^^^^..........rr...........rr.", + ".......................~~~.......................................^^^^^^.......................rr", + "......................~~~~~.....................................^^^^^^^^........................", + "......................~~~~~......................................^^^^^^^........................", + "......................~~~~.....rr..............................rrrr...........................rr", + ".......................~~......rr...........................rrrrr.......................rr....rr", + "...............................r...rr.................rr....rrrr.........................r....rr", + "................................rrrrr................rrr.....rrr................................", + "...............MM................rrrr..............rrrr.......rrr...............................", + "...............MM...............rrr...............rrrrr...rr..rr................................", + "......r..........................rr..r............rrrr....rrrrrr................rrr............r", + "r....MM.............................rrr.........................................rrr.............", + ".....MM..............................rr...........................~~~~~~~~~~~~~~~~~.............", + ".......................^^^^..rr......rr...r~~~..................~~~~~~~~~~~~~~~~~~~~............", + ".........MM...........^^^^^.rrr......rr...~~~~~................~~~~~~~~~~~~~~~~~~~~~~~..........", + ".........MM.........^^^^^^^rrrr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~..rr....r", + "^^................r^^^^^rr...rr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~.rrrrrrrr", + "^^^...............^^^^^..................~~~~~~~.......rr......~~~~~~~~~~~~~~~~~~~~~~~~~.rrrr...", + "^^^................^^^^...................~~~~~........rr.......~~~~~~~~~~~~~~~~~~~~~~~~~~......", + "r..................^^^^.........................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~....", + "r..................rr^............................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~..", + ".......................................................................~~~~~~~~~~~~~~~~~~~~~~~~.", + "...rr..................rr.................................................~~~~~~~~~~~~~~~~~~~~~~", + "r..rr..................rrr.......................................................~~~~~~~~~~~~~~~", + "r..rr..................rrrr........................................................~~~~~~~~~~~~~", + "...rr....................rr..........rr....................r........................~~~~~~~~~~~~", + "..........................rr.........rr...................rrr.......................~~~~~~~~~~~~", + "..........................rr......................rr.......r........................r~~~~~~~~~~~", + ".........................rrrr...rr................rr.................................r~~~~~~~~~~", + ".........................rrrr...rr..r.............rr.................................rrr~~~~~~..", + ".........................r.r.....rrrrrr.............................................rrrrrrrrr...", + ".................................rr..rr................^^...........................rr.rr.......", + "................................rr...................^^^^^..rr..................................", + ".....rrr............~~..........rr..............^^^^^^^^^^..rr..................................", + ".....rrr.......rr...~~..........rr........^^^^^^^^^^^^^^^^..rr..................................", + ".......rr......rr..~~~..........rr.......^^^^^^^^^^^^^^^^^..rr..................................", + "........rr.........~~~..................^^^^^^^^^^^^^^^^^^..rrrr..............^.................", + "........rr.........~~~~.................^^^^^^^^^^^^^^^^^^^.rrrr.............^^^................", + "...................~~~~................^^^^^^^^^^^^^^^^^^^..rrr..............^^^rrr......rrrrrrr", + "...................~~~..................^^^^^^^^^^^^^^^^^^..rr...............^^^rrr......rrrrrrr", + "....................~~..................^^^^^^^^^^^^^^^^......................^................r", + "r................^......................^^^^^^^^^^^^^^^^..................~~....................", + "rrrrrrr......rrr^^^...............rr..^^^^^^^^^^^^^^^^^^..................~~~...................", + "rrrrrrr......rrr^^^..............rrr..^^^^^^^^^^^^^^^^^^^................~~~~...................", + "................^^^.............rrrr.^^^^^^^^^^^^^^^^^^^.................~~~~.........rr........", + ".................^..............rrrr..^^^^^^^^^^^^^^^^^^..................~~~.........rr........", + "..................................rr..^^^^^^^^^^^^^^^^^.......rr..........~~~..rr......rr.......", + "..................................rr..^^^^^^^^^^^^^^^^........rr..........~~...rr.......rrr.....", + "..................................rr..^^^^^^^^^^..............rr..........~~............rrr.....", + "..................................rr..^^^^^...................rr................................", + ".......rr.rr...........................^^................rr..rr.................................", + "...rrrrrrrrr.............................................rrrrrr.....r.r.........................", + "..~~~~~~rrr.................................rr.............r..rr...rrrr.........................", + "~~~~~~~~~~r.................................rr................rr...rrrr.........................", + "~~~~~~~~~~~r........................r.......rr......................rr..........................", + "~~~~~~~~~~~~.......................rrr...................rr.........rr..........................", + "~~~~~~~~~~~~........................r....................rr..........rr....................rr...", + "~~~~~~~~~~~~~........................................................rrrr..................rr..r", + "~~~~~~~~~~~~~~~.......................................................rrr..................rr..r", + "~~~~~~~~~~~~~~~~~~~~~~.................................................rr..................rr...", + ".~~~~~~~~~~~~~~~~~~~~~~~~.......................................................................", + "..~~~~~~~~~~~~~~~~~~~~~~~~~~~~............................................^rr..................r", + "....~~~~~~~~~~~~~~~~~~~~~~~~~~~~.........................................^^^^..................r", + "......~~~~~~~~~~~~~~~~~~~~~~~~~~.......rr........~~~~~...................^^^^................^^^", + "...rrrr.~~~~~~~~~~~~~~~~~~~~~~~~~......rr.......~~~~~~~..................^^^^^...............^^^", + "rrrrrrrr.~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rr...rr^^^^^r................^^", + "r....rr..~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rrrr^^^^^^^.........MM.........", + "..........~~~~~~~~~~~~~~~~~~~~~~~................~~~~~...rr......rrr.^^^^^...........MM.........", + "............~~~~~~~~~~~~~~~~~~~~..................~~~r...rr......rr..^^^^.......................", + ".............~~~~~~~~~~~~~~~~~...........................rr..............................MM.....", + ".............rrr.........................................rrr.............................MM....r", + "r............rrr................rrrrrr....rrrr............r..rr..........................r......", + "................................rr..rr...rrrrr...............rrr...............MM...............", + "...............................rrr.......rrrr..............rrrr................MM...............", + "................................rrr.....rrr................rrrrr................................", + "rr....r.........................rrrr....rr.................rr...r...............................", + "rr....rr.......................rrrrr...........................rr......~~.......................", + "rr...........................rrrr..............................rr.....~~~~......................", + "........................^^^^^^^......................................~~~~~......................", + "........................^^^^^^^^.....................................~~~~~......................", + "rr.......................^^^^^^.......................................~~~.......................", + ".rr...........rr..........^^^^^r................................................................", + ".rr............rr.......rr..^.........................................rr........................", + "..r.....................rrrr..........................................rr........................", + "...rrr...................rrr....................................................................", + "...rrr...............................................r..............^^^^^^......................", + "...rr..............................................rr...............^^^^^^......MM..............", + "...rrr............................................^^^^^..............^^^^^r..^^.MM..............", + "...rrr...........r....r.........r....rr...r.......^^^^^^.............^^^^^...^^^................" + ], + "starts": [ + { + "army": 0, + "x": 10, + "y": 10 + }, + { + "army": 1, + "x": 85, + "y": 85 + } + ], + "buildings": [ + { + "army": 0, + "type": "energygen", + "tx": 13, + "ty": 7 + }, + { + "army": 0, + "type": "energygen", + "tx": 7, + "ty": 7 + }, + { + "army": 0, + "type": "barracks", + "tx": 14, + "ty": 13 + }, + { + "army": 0, + "type": "vehicleplant", + "tx": 6, + "ty": 13 + }, + { + "army": 1, + "type": "energygen", + "tx": 88, + "ty": 82 + }, + { + "army": 1, + "type": "barracks", + "tx": 89, + "ty": 88 + } + ] + } + } + ] +} diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index c7a6e3d..f7aa190 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -1,8 +1,8 @@ { "_readme": [ - "Engine data for Total Annihilation. Everything the simulation knows lives here — adding a unit,", + "Engine data for Total Annihilation. Everything the simulation knows lives here \u2014 adding a unit,", "building, weapon, terrain type, army or map theme is a JSON-only change.", - "After editing, run `node tools/verifyTotalAnnihilation.js` — it cross-checks every id reference,", + "After editing, run `node tools/verifyTotalAnnihilation.js` \u2014 it cross-checks every id reference,", "frame index and closed key set in this file and will tell you exactly what broke.", "", "Sheet resolution: a unit/building's `sheetSlot` names a field on its ARMY (unitSheet or", @@ -10,12 +10,10 @@ "third army a two-line change: add an `armies` entry pointing at two new sheets in", "data/totalannihilation-artwork.json.", "", - "Units are drawn once and ROTATED at runtime — there are no per-facing frames. `turretFrame` is", + "Units are drawn once and ROTATED at runtime \u2014 there are no per-facing frames. `turretFrame` is", "an optional second sprite that aims independently of the hull." ], - "version": 1, - "constants": { "tickHz": 20, "tileSize": 64, @@ -35,236 +33,766 @@ "buildPowerNominal": 100, "eliminateWhenUnrecoverable": true }, - "sizeClasses": { - "small": { "radius": 16, "footprint": 1, "mass": 1 }, - "medium": { "radius": 26, "footprint": 1, "mass": 4 }, - "large": { "radius": 44, "footprint": 2, "mass": 16 } - }, - - "armorClasses": ["infantry", "light", "medium", "heavy", "structure"], - - "moveClasses": { - "foot": { - "cost": { "ground": 1.0, "rough": 1.3, "road": 0.85, "metal": 1.0, "cliff": null, "water": null } + "small": { + "radius": 16, + "footprint": 1, + "mass": 1 }, - "tread": { - "cost": { "ground": 1.0, "rough": 1.8, "road": 0.7, "metal": 1.0, "cliff": null, "water": null } + "medium": { + "radius": 26, + "footprint": 1, + "mass": 4 }, - "wheel": { - "cost": { "ground": 1.0, "rough": 2.4, "road": 0.6, "metal": 1.0, "cliff": null, "water": null } + "large": { + "radius": 44, + "footprint": 2, + "mass": 16 } }, - - "armies": [ - { "id": "arm", "name": "ARM", "color": "#4a90e2", - "unitSheet": "arm-units", "structureSheet": "arm-structures" }, - { "id": "core", "name": "CORE", "color": "#e05a2f", - "unitSheet": "core-units", "structureSheet": "core-structures" } + "armorClasses": [ + "infantry", + "light", + "medium", + "heavy", + "structure" ], - - "commanders": [ - { "id": "vance", "armyId": "arm", "opponentId": "ethel", "name": "Commander Ethel", - "tagline": "Holds the line, and makes you pay for every metre of it.", - "aiProfile": { "aggression": 0.45, "expansion": 0.65 } }, - { "id": "klaxon", "armyId": "core", "opponentId": "klaxon", "name": "Unit KLAXON", - "tagline": "Efficiency is a weapon. You are an inefficiency.", - "aiProfile": { "aggression": 0.7, "expansion": 0.45 } }, - { "id": "cybro", "armyId": "core", "opponentId": "cybro", "name": "Cy-Bro", - "tagline": "Builds faster than you can knock it down.", - "aiProfile": { "aggression": 0.35, "expansion": 0.85 } }, - { "id": "nadia", "armyId": "arm", "opponentId": "nadia", "name": "Commander Nadia", - "tagline": "Hits the flank while you watch the front.", - "aiProfile": { "aggression": 0.8, "expansion": 0.4 } } - ], - - "terrain": [ - { "id": "ground", "ch": ".", "frame": 0, "buildable": true, "blocksMove": false, "elevation": 0 }, - { "id": "rough", "ch": "r", "frame": 3, "buildable": true, "blocksMove": false, "elevation": 0 }, - { "id": "road", "ch": "=", "frame": 8, "buildable": true, "blocksMove": false, "elevation": 0 }, - { "id": "metal", "ch": "M", "frame": 7, "buildable": true, "blocksMove": false, "elevation": 0, - "massMultiplier": 2.0 }, - { "id": "cliff", "ch": "^", "frame": 4, "buildable": false, "blocksMove": true, "blocksFire": true, "elevation": 1 }, - { "id": "water", "ch": "~", "frame": 5, "buildable": false, "blocksMove": true, "elevation": -1 } - ], - - "weapons": [ - { "id": "rifle", "name": "Assault Rifle", "kind": "hitscan", - "damage": 6, "reload": 0.5, "burst": 3, "burstDelay": 0.06, - "range": 200, "spread": 0.05, "targets": ["ground"], - "armorMul": { "infantry": 1.4, "light": 0.8, "medium": 0.35, "heavy": 0.15, "structure": 0.2 }, - "fx": { "style": "tracer", "color": "#ffe6a0", "width": 1.5, "lifeMs": 70 }, - "sound": "sfx-battle-gunfire-modern" }, - - { "id": "sniperrifle", "name": "Marksman Rifle", "kind": "hitscan", - "damage": 55, "reload": 3.0, "burst": 1, - "range": 460, "spread": 0.0, "targets": ["ground"], - "armorMul": { "infantry": 2.2, "light": 0.9, "medium": 0.3, "heavy": 0.1, "structure": 0.1 }, - "fx": { "style": "beam", "color": "#d8f0ff", "width": 2, "lifeMs": 160 }, - "sound": "sfx-laser-zap" }, - - { "id": "tankgun", "name": "120mm Cannon", "kind": "ballistic", - "damage": 90, "reload": 2.4, "burst": 1, - "range": 340, "speed": 700, "spread": 0.015, "leadTarget": true, - "aoe": 48, "aoeFalloff": 0.4, "targets": ["ground"], - "armorMul": { "infantry": 0.5, "light": 1.0, "medium": 1.0, "heavy": 0.8, "structure": 1.2 }, - "fx": { "style": "shell", "color": "#ffd27a", "width": 3, "muzzle": 18 }, - "sound": "sfx-battle-tank" }, - - { "id": "rocketpod", "name": "Rocket Pod", "kind": "guided", - "damage": 130, "reload": 4.0, "burst": 2, "burstDelay": 0.25, - "range": 480, "minRange": 140, "speed": 340, "turnRate": 2.2, - "aoe": 80, "aoeFalloff": 0.35, "targets": ["ground"], - "armorMul": { "infantry": 0.6, "light": 1.1, "medium": 1.2, "heavy": 1.3, "structure": 1.4 }, - "fx": { "style": "rocket", "color": "#ff8844", "width": 2, "trail": true }, - "sound": "sfx-battle-missle" }, - - { "id": "dgun", "name": "D-Gun", "kind": "beam", - "damage": 2000, "reload": 1.5, "burst": 1, - "range": 220, "energyPerShot": 500, "targets": ["ground"], - "armorMul": { "infantry": 1, "light": 1, "medium": 1, "heavy": 1, "structure": 1 }, - "fx": { "style": "dgun", "color": "#8ad4ff", "width": 9, "lifeMs": 260 }, - "sound": "sfx-scifi-launch" } - ], - - "units": [ - { "id": "commander", "name": "Commander", "role": "builder", - "size": "medium", "radius": 20, - "hp": 3000, "speed": 62, "turnRate": 3.5, "moveClass": "foot", - "armorClass": "heavy", "sight": 420, - "cost": { "energy": 0, "mass": 0 }, "buildTime": 0, - "buildPower": 100, "buildRange": 200, - "builds": ["energygen", "massgen", "barracks", "vehicleplant"], - "produce": { "energy": 25, "mass": 3.0 }, - "storage": { "energy": 1000, "mass": 1000 }, - "weapons": ["dgun"], - "deathExplosion": { "radius": 280, "damage": 1200 }, - "sheetSlot": "unitSheet", "frame": 0, "turretFrame": 1, - "procShape": "commander", "icon": 0, "spritePx": 56 }, - - { "id": "infantry", "name": "Infantry", "role": "combat", - "size": "small", "radius": 16, - "hp": 90, "speed": 55, "turnRate": 6.0, "moveClass": "foot", - "armorClass": "infantry", "sight": 260, - "cost": { "energy": 120, "mass": 24 }, "buildTime": 6, - "builtBy": ["barracks"], - "weapons": ["rifle"], - "sheetSlot": "unitSheet", "frame": 2, - "procShape": "infantry", "icon": 1, "spritePx": 30, - "moveSound": "sfx-march" }, - - { "id": "sniper", "name": "Sniper", "role": "combat", - "size": "small", "radius": 16, - "hp": 70, "speed": 46, "turnRate": 5.0, "moveClass": "foot", - "armorClass": "infantry", "sight": 500, - "cost": { "energy": 260, "mass": 55 }, "buildTime": 12, - "builtBy": ["barracks"], - "weapons": ["sniperrifle"], - "sheetSlot": "unitSheet", "frame": 3, - "procShape": "sniper", "icon": 2, "spritePx": 30, - "moveSound": "sfx-march" }, - - { "id": "jeep", "name": "Jeep", "role": "scout", - "size": "small", "radius": 20, - "hp": 180, "speed": 175, "turnRate": 3.4, "moveClass": "wheel", - "armorClass": "light", "sight": 520, - "cost": { "energy": 320, "mass": 70 }, "buildTime": 8, - "builtBy": ["vehicleplant"], - "weapons": ["rifle"], - "sheetSlot": "unitSheet", "frame": 4, "turretFrame": 5, - "procShape": "jeep", "icon": 3, "spritePx": 40, - "moveSound": "sfx-engine-medium" }, - - { "id": "tank", "name": "Tank", "role": "combat", - "size": "medium", "radius": 26, - "hp": 520, "speed": 90, "turnRate": 2.2, "moveClass": "tread", - "armorClass": "medium", "sight": 300, - "cost": { "energy": 700, "mass": 180 }, "buildTime": 20, - "builtBy": ["vehicleplant"], - "weapons": ["tankgun"], - "sheetSlot": "unitSheet", "frame": 6, "turretFrame": 7, - "procShape": "tank", "icon": 4, "spritePx": 54, - "moveSound": "sfx-engine-heavy" }, - - { "id": "rockettank", "name": "Rocket Tank", "role": "artillery", - "size": "medium", "radius": 28, - "hp": 400, "speed": 75, "turnRate": 1.8, "moveClass": "tread", - "armorClass": "medium", "sight": 320, - "cost": { "energy": 1100, "mass": 300 }, "buildTime": 28, - "builtBy": ["vehicleplant"], - "weapons": ["rocketpod"], - "sheetSlot": "unitSheet", "frame": 8, "turretFrame": 9, - "procShape": "rockettank", "icon": 5, "spritePx": 56, - "moveSound": "sfx-engine-heavy" } - ], - - "buildings": [ - { "id": "energygen", "name": "Energy Generator", - "footprint": { "w": 2, "h": 2 }, - "hp": 800, "armorClass": "structure", "sight": 160, - "cost": { "energy": 200, "mass": 60 }, "buildTime": 12, - "produce": { "energy": 20 }, - "storage": { "energy": 500 }, - "deathExplosion": { "radius": 110, "damage": 160 }, - "sheetSlot": "structureSheet", "frame": 0, "buildFrame": 1, - "procShape": "energyGen", "icon": 10 }, - - { "id": "massgen", "name": "Mass Generator", - "footprint": { "w": 2, "h": 2 }, - "hp": 700, "armorClass": "structure", "sight": 160, - "cost": { "energy": 600, "mass": 50 }, "buildTime": 16, - "produce": { "mass": 2.0 }, - "upkeep": { "energy": 15 }, - "storage": { "mass": 250 }, - "terrainMultiplier": "massMultiplier", - "sheetSlot": "structureSheet", "frame": 2, "buildFrame": 3, - "procShape": "massGen", "icon": 11 }, - - { "id": "barracks", "name": "Barracks", - "footprint": { "w": 3, "h": 3 }, - "hp": 1400, "armorClass": "structure", "sight": 220, - "cost": { "energy": 600, "mass": 180 }, "buildTime": 26, - "buildPower": 100, "builds": ["infantry", "sniper"], - "spawnOffset": { "x": 0, "y": 2.2 }, - "sheetSlot": "structureSheet", "frame": 4, "buildFrame": 5, - "procShape": "barracks", "icon": 12 }, - - { "id": "vehicleplant", "name": "Vehicle Plant", - "footprint": { "w": 3, "h": 3 }, - "hp": 2200, "armorClass": "structure", "sight": 220, - "cost": { "energy": 1400, "mass": 520 }, "buildTime": 40, - "buildPower": 100, "builds": ["jeep", "tank", "rockettank"], - "spawnOffset": { "x": 0, "y": 2.2 }, - "sheetSlot": "structureSheet", "frame": 6, "buildFrame": 7, - "procShape": "vehiclePlant", "icon": 13 } - ], - - "commandIcons": { - "move": 20, "attack": 21, "attackMove": 22, "stop": 23, - "hold": 24, "patrol": 25, "guard": 26, "assist": 27 - }, - - "aiSkills": [ - { "skill": 1, "decisionPeriodMs": 1500, "apmCap": 1, "squadSize": 3, "expansion": 0.6, "microLevel": 0, "reactionMs": 4000 }, - { "skill": 2, "decisionPeriodMs": 1100, "apmCap": 2, "squadSize": 4, "expansion": 1.0, "microLevel": 1, "reactionMs": 3000 }, - { "skill": 3, "decisionPeriodMs": 800, "apmCap": 4, "squadSize": 6, "expansion": 1.6, "microLevel": 2, "reactionMs": 2000 }, - { "skill": 4, "decisionPeriodMs": 500, "apmCap": 8, "squadSize": 8, "expansion": 2.2, "microLevel": 3, "reactionMs": 1000 }, - { "skill": 5, "decisionPeriodMs": 300, "apmCap": 15, "squadSize": 10, "expansion": 3.0, "microLevel": 4, "reactionMs": 300 } - ], - - "skirmish": { - "sizes": { "small": 64, "medium": 96, "large": 128 }, - "symmetries": ["mirror-x", "mirror-y", "rotational"], - "themes": ["grasslands", "snowfields", "tropics"], - "defaults": { - "size": "medium", "symmetry": "mirror-x", "theme": "grasslands", - "aiSkill": 3, "playerArmy": "arm" + "moveClasses": { + "foot": { + "cost": { + "ground": 1.0, + "rough": 1.3, + "road": 0.85, + "metal": 1.0, + "cliff": null, + "water": null + } }, - "startUnits": ["commander"], + "tread": { + "cost": { + "ground": 1.0, + "rough": 1.8, + "road": 0.7, + "metal": 1.0, + "cliff": null, + "water": null + } + }, + "wheel": { + "cost": { + "ground": 1.0, + "rough": 2.4, + "road": 0.6, + "metal": 1.0, + "cliff": null, + "water": null + } + } + }, + "armies": [ + { + "id": "arm", + "name": "ARM", + "color": "#4a90e2", + "unitSheet": "arm-units", + "structureSheet": "arm-structures" + }, + { + "id": "core", + "name": "CORE", + "color": "#e05a2f", + "unitSheet": "core-units", + "structureSheet": "core-structures" + } + ], + "commanders": [ + { + "id": "vance", + "armyId": "arm", + "opponentId": "ethel", + "name": "Commander Ethel", + "tagline": "Holds the line, and makes you pay for every metre of it.", + "aiProfile": { + "aggression": 0.45, + "expansion": 0.65 + } + }, + { + "id": "klaxon", + "armyId": "core", + "opponentId": "klaxon", + "name": "Unit KLAXON", + "tagline": "Efficiency is a weapon. You are an inefficiency.", + "aiProfile": { + "aggression": 0.7, + "expansion": 0.45 + } + }, + { + "id": "cybro", + "armyId": "core", + "opponentId": "cybro", + "name": "Cy-Bro", + "tagline": "Builds faster than you can knock it down.", + "aiProfile": { + "aggression": 0.35, + "expansion": 0.85 + } + }, + { + "id": "nadia", + "armyId": "arm", + "opponentId": "nadia", + "name": "Commander Nadia", + "tagline": "Hits the flank while you watch the front.", + "aiProfile": { + "aggression": 0.8, + "expansion": 0.4 + } + } + ], + "terrain": [ + { + "id": "ground", + "ch": ".", + "frame": 0, + "buildable": true, + "blocksMove": false, + "elevation": 0 + }, + { + "id": "rough", + "ch": "r", + "frame": 3, + "buildable": true, + "blocksMove": false, + "elevation": 0 + }, + { + "id": "road", + "ch": "=", + "frame": 8, + "buildable": true, + "blocksMove": false, + "elevation": 0 + }, + { + "id": "metal", + "ch": "M", + "frame": 7, + "buildable": true, + "blocksMove": false, + "elevation": 0, + "massMultiplier": 2.0 + }, + { + "id": "cliff", + "ch": "^", + "frame": 4, + "buildable": false, + "blocksMove": true, + "blocksFire": true, + "elevation": 1 + }, + { + "id": "water", + "ch": "~", + "frame": 5, + "buildable": false, + "blocksMove": true, + "elevation": -1 + } + ], + "weapons": [ + { + "id": "rifle", + "name": "Assault Rifle", + "kind": "hitscan", + "damage": 6, + "reload": 0.5, + "burst": 3, + "burstDelay": 0.06, + "range": 200, + "spread": 0.05, + "targets": [ + "ground" + ], + "armorMul": { + "infantry": 1.3, + "light": 0.85, + "medium": 0.3, + "heavy": 0.12, + "structure": 0.2 + }, + "fx": { + "style": "tracer", + "color": "#ffe6a0", + "width": 1.5, + "lifeMs": 70 + }, + "sound": "sfx-battle-gunfire-modern" + }, + { + "id": "sniperrifle", + "name": "Marksman Rifle", + "kind": "hitscan", + "damage": 55, + "reload": 3.0, + "burst": 1, + "range": 460, + "spread": 0.0, + "targets": [ + "ground" + ], + "armorMul": { + "infantry": 2.2, + "light": 0.9, + "medium": 0.3, + "heavy": 0.1, + "structure": 0.1 + }, + "fx": { + "style": "beam", + "color": "#d8f0ff", + "width": 2, + "lifeMs": 160 + }, + "sound": "sfx-laser-zap" + }, + { + "id": "tankgun", + "name": "120mm Cannon", + "kind": "ballistic", + "damage": 95, + "reload": 2.0, + "burst": 1, + "range": 350, + "speed": 700, + "spread": 0.015, + "leadTarget": true, + "aoe": 40, + "aoeFalloff": 0.15, + "targets": [ + "ground" + ], + "armorMul": { + "infantry": 1.3, + "light": 1.15, + "medium": 1.0, + "heavy": 0.8, + "structure": 1.2 + }, + "fx": { + "style": "shell", + "color": "#ffd27a", + "width": 3, + "muzzle": 18 + }, + "sound": "sfx-battle-tank" + }, + { + "id": "rocketpod", + "name": "Rocket Pod", + "kind": "guided", + "damage": 130, + "reload": 4.0, + "burst": 2, + "burstDelay": 0.25, + "range": 440, + "minRange": 110, + "speed": 340, + "turnRate": 2.2, + "aoe": 48, + "aoeFalloff": 0.15, + "targets": [ + "ground" + ], + "armorMul": { + "infantry": 0.3, + "light": 0.6, + "medium": 1.0, + "heavy": 1.4, + "structure": 1.5 + }, + "fx": { + "style": "rocket", + "color": "#ff8844", + "width": 2, + "trail": true + }, + "sound": "sfx-battle-missle" + }, + { + "id": "commanderlaser", + "name": "Commander Laser", + "kind": "hitscan", + "damage": 38, + "reload": 1.1, + "burst": 1, + "range": 260, + "spread": 0.01, + "targets": [ + "ground" + ], + "armorMul": { + "infantry": 1.3, + "light": 1.0, + "medium": 0.8, + "heavy": 0.6, + "structure": 0.5 + }, + "fx": { + "style": "beam", + "color": "#9ff0c8", + "width": 3, + "lifeMs": 120 + }, + "sound": "sfx-laser-zap" + }, + { + "id": "dgun", + "name": "D-Gun", + "kind": "beam", + "damage": 2000, + "reload": 1.5, + "burst": 1, + "range": 220, + "energyPerShot": 500, + "targets": [ + "ground" + ], + "manual": true, + "armorMul": { + "infantry": 1, + "light": 1, + "medium": 1, + "heavy": 1, + "structure": 1 + }, + "fx": { + "style": "dgun", + "color": "#8ad4ff", + "width": 9, + "lifeMs": 260 + }, + "sound": "sfx-scifi-launch" + } + ], + "units": [ + { + "id": "commander", + "name": "Commander", + "role": "builder", + "size": "medium", + "radius": 20, + "hp": 7000, + "speed": 62, + "turnRate": 3.5, + "moveClass": "foot", + "armorClass": "heavy", + "sight": 420, + "cost": { + "energy": 0, + "mass": 0 + }, + "buildTime": 0, + "buildPower": 100, + "buildRange": 200, + "builds": [ + "energygen", + "massgen", + "barracks", + "vehicleplant" + ], + "produce": { + "energy": 25, + "mass": 3.0 + }, + "storage": { + "energy": 1000, + "mass": 1000 + }, + "weapons": [ + "commanderlaser", + "dgun" + ], + "deathExplosion": { + "radius": 280, + "damage": 1200 + }, + "sheetSlot": "unitSheet", + "frame": 0, + "turretFrame": 1, + "procShape": "commander", + "icon": 0, + "spritePx": 56 + }, + { + "id": "infantry", + "name": "Infantry", + "role": "combat", + "size": "small", + "radius": 16, + "hp": 260, + "speed": 55, + "turnRate": 6.0, + "moveClass": "foot", + "armorClass": "infantry", + "sight": 260, + "cost": { + "energy": 120, + "mass": 24 + }, + "buildTime": 6, + "builtBy": [ + "barracks" + ], + "weapons": [ + "rifle" + ], + "sheetSlot": "unitSheet", + "frame": 2, + "procShape": "infantry", + "icon": 1, + "spritePx": 30, + "moveSound": "sfx-march" + }, + { + "id": "sniper", + "name": "Sniper", + "role": "combat", + "size": "small", + "radius": 16, + "hp": 200, + "speed": 46, + "turnRate": 5.0, + "moveClass": "foot", + "armorClass": "infantry", + "sight": 500, + "cost": { + "energy": 260, + "mass": 55 + }, + "buildTime": 12, + "builtBy": [ + "barracks" + ], + "weapons": [ + "sniperrifle" + ], + "sheetSlot": "unitSheet", + "frame": 3, + "procShape": "sniper", + "icon": 2, + "spritePx": 30, + "moveSound": "sfx-march" + }, + { + "id": "jeep", + "name": "Jeep", + "role": "scout", + "size": "small", + "radius": 20, + "hp": 480, + "speed": 175, + "turnRate": 3.4, + "moveClass": "wheel", + "armorClass": "light", + "sight": 520, + "cost": { + "energy": 320, + "mass": 70 + }, + "buildTime": 8, + "builtBy": [ + "vehicleplant" + ], + "weapons": [ + "rifle" + ], + "sheetSlot": "unitSheet", + "frame": 4, + "turretFrame": 5, + "procShape": "jeep", + "icon": 3, + "spritePx": 40, + "moveSound": "sfx-engine-medium" + }, + { + "id": "tank", + "name": "Tank", + "role": "combat", + "size": "medium", + "radius": 26, + "hp": 1900, + "speed": 90, + "turnRate": 2.2, + "moveClass": "tread", + "armorClass": "medium", + "sight": 300, + "cost": { + "energy": 700, + "mass": 180 + }, + "buildTime": 20, + "builtBy": [ + "vehicleplant" + ], + "weapons": [ + "tankgun" + ], + "sheetSlot": "unitSheet", + "frame": 6, + "turretFrame": 7, + "procShape": "tank", + "icon": 4, + "spritePx": 54, + "moveSound": "sfx-engine-heavy" + }, + { + "id": "rockettank", + "name": "Rocket Tank", + "role": "artillery", + "size": "medium", + "radius": 28, + "hp": 1250, + "speed": 75, + "turnRate": 1.8, + "moveClass": "tread", + "armorClass": "medium", + "sight": 320, + "cost": { + "energy": 1100, + "mass": 300 + }, + "buildTime": 28, + "builtBy": [ + "vehicleplant" + ], + "weapons": [ + "rocketpod" + ], + "sheetSlot": "unitSheet", + "frame": 8, + "turretFrame": 9, + "procShape": "rockettank", + "icon": 5, + "spritePx": 56, + "moveSound": "sfx-engine-heavy" + } + ], + "buildings": [ + { + "id": "energygen", + "name": "Energy Generator", + "footprint": { + "w": 2, + "h": 2 + }, + "hp": 1440, + "armorClass": "structure", + "sight": 160, + "cost": { + "energy": 200, + "mass": 60 + }, + "buildTime": 12, + "produce": { + "energy": 20 + }, + "storage": { + "energy": 500 + }, + "deathExplosion": { + "radius": 110, + "damage": 160 + }, + "sheetSlot": "structureSheet", + "frame": 0, + "buildFrame": 1, + "procShape": "energyGen", + "icon": 10 + }, + { + "id": "massgen", + "name": "Mass Generator", + "footprint": { + "w": 2, + "h": 2 + }, + "hp": 1260, + "armorClass": "structure", + "sight": 160, + "cost": { + "energy": 600, + "mass": 50 + }, + "buildTime": 16, + "produce": { + "mass": 2.0 + }, + "upkeep": { + "energy": 15 + }, + "storage": { + "mass": 250 + }, + "terrainMultiplier": "massMultiplier", + "sheetSlot": "structureSheet", + "frame": 2, + "buildFrame": 3, + "procShape": "massGen", + "icon": 11 + }, + { + "id": "barracks", + "name": "Barracks", + "footprint": { + "w": 3, + "h": 3 + }, + "hp": 2520, + "armorClass": "structure", + "sight": 220, + "cost": { + "energy": 600, + "mass": 180 + }, + "buildTime": 26, + "buildPower": 100, + "builds": [ + "infantry", + "sniper" + ], + "spawnOffset": { + "x": 0, + "y": 2.2 + }, + "sheetSlot": "structureSheet", + "frame": 4, + "buildFrame": 5, + "procShape": "barracks", + "icon": 12 + }, + { + "id": "vehicleplant", + "name": "Vehicle Plant", + "footprint": { + "w": 3, + "h": 3 + }, + "hp": 3960, + "armorClass": "structure", + "sight": 220, + "cost": { + "energy": 1400, + "mass": 520 + }, + "buildTime": 40, + "buildPower": 100, + "builds": [ + "jeep", + "tank", + "rockettank" + ], + "spawnOffset": { + "x": 0, + "y": 2.2 + }, + "sheetSlot": "structureSheet", + "frame": 6, + "buildFrame": 7, + "procShape": "vehiclePlant", + "icon": 13 + } + ], + "commandIcons": { + "move": 20, + "attack": 21, + "attackMove": 22, + "stop": 23, + "hold": 24, + "patrol": 25, + "guard": 26, + "assist": 27 + }, + "aiSkills": [ + { + "skill": 1, + "decisionPeriodMs": 1500, + "apmCap": 1, + "squadSize": 3, + "expansion": 0.6, + "microLevel": 0, + "reactionMs": 4000 + }, + { + "skill": 2, + "decisionPeriodMs": 1100, + "apmCap": 2, + "squadSize": 4, + "expansion": 1.0, + "microLevel": 1, + "reactionMs": 3000 + }, + { + "skill": 3, + "decisionPeriodMs": 800, + "apmCap": 4, + "squadSize": 6, + "expansion": 1.6, + "microLevel": 2, + "reactionMs": 2000 + }, + { + "skill": 4, + "decisionPeriodMs": 500, + "apmCap": 8, + "squadSize": 8, + "expansion": 2.2, + "microLevel": 3, + "reactionMs": 1000 + }, + { + "skill": 5, + "decisionPeriodMs": 300, + "apmCap": 15, + "squadSize": 10, + "expansion": 3.0, + "microLevel": 4, + "reactionMs": 300 + } + ], + "skirmish": { + "sizes": { + "small": 64, + "medium": 96, + "large": 128 + }, + "symmetries": [ + "mirror-x", + "mirror-y", + "rotational" + ], + "themes": [ + "grasslands", + "snowfields", + "tropics" + ], + "defaults": { + "size": "medium", + "symmetry": "mirror-x", + "theme": "grasslands", + "aiSkill": 3, + "playerArmy": "arm" + }, + "startUnits": [ + "commander" + ], "gen": { - "waterFraction": 0.10, + "waterFraction": 0.1, "roughFraction": 0.18, - "cliffFraction": 0.10, + "cliffFraction": 0.1, "metalSpotsPerStart": 4, "metalSpotRadiusTiles": 14, "minStartSeparationTiles": 40, diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index e598d44..4e49c47 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -233,6 +233,23 @@ export const MANIFEST = { // round begins; anything missing just never plays. (scene) => musicFrom(scene, 'nintendo-music'), ], + // Rules always load; every unit/structure/terrain sheet is an optional drop-in + // declared in data/totalannihilation-artwork.json (spec: src/games/ + // totalannihilation/sprites.md) — each is path:null today and paints + // procedurally, so the game is fully playable with zero PNGs. `sheets` is a MAP + // of sheets rather than a list of field names, which is why one resolver line + // covers every sheet ever added: sheetsFrom() Object.values() anything without a + // `key`. Uses the hacker soundtrack (see services/soundtrack.js). + totalannihilation: [ + { type: 'json', key: 'totalannihilation-rules', path: 'data/totalannihilation-rules.json' }, + { type: 'json', key: 'totalannihilation-campaign', path: 'data/totalannihilation-campaign.json' }, + (scene) => sheetsFrom(scene, 'totalannihilation-artwork', ['sheets']), + // Weapon sounds keyed by each weapon's `sound` in totalannihilation-rules.json. + ...['gunfire-modern', 'tank', 'missle'].map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })), + { type: 'audio', key: 'sfx-laser-zap', path: 'assets/fx/laser-zap.mp3' }, + { type: 'audio', key: 'sfx-scifi-launch', path: 'assets/fx/scifi-launch.mp3' }, + (scene) => musicFrom(scene, 'hacker-music'), + ], coloradodefense: [ // arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here // so its audio only downloads once Colorado Defense is actually entered. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 37d0723..f20d74c 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -115,3 +115,4 @@ registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 85 }); registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 }); registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 }); +registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 }); diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js index 31d8287..6598599 100644 --- a/src/games/totalannihilation/TAAI.js +++ b/src/games/totalannihilation/TAAI.js @@ -25,7 +25,7 @@ function memFor(state, armyIdx) { m = state.aiMem[armyIdx] = { nextThinkTick: 0, ordersThisSecond: 0, secondMark: 0, - squad: [], squadPeak: 0, phase: 'build', + squad: [], squadPeak: 0, phase: 'build', focusId: 0, scoutId: 0, knownEnemies: [], // [{x,y,defId,isBuilding,tick}] lastAttackTick: -99999, @@ -46,7 +46,10 @@ export function runAI(rules, state, armyIdx, profile = {}) { if (!army || !army.alive) return; const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3)); - const tuning = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1]; + const base = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1]; + // `tune` lets a mission (or a tuning experiment) override individual dials without + // inventing a whole new skill row. + const tuning = profile.tune ? { ...base, ...profile.tune } : base; const aggression = profile.aggression ?? army.aiProfile?.aggression ?? 0.5; // Economic ambition is primarily a SKILL trait — tuning.expansion runs 0.6..3.0 across // skills 1-5, normalised here to 0.2..1.0. A commander's own aiProfile only nudges it, @@ -68,7 +71,7 @@ export function runAI(rules, state, armyIdx, profile = {}) { mem.ordersThisSecond = 0; } const ctx = { - rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, + rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, profile, budget: Math.max(1, tuning.apmCap), }; @@ -165,7 +168,7 @@ function manageEconomyAndBase(ctx, mine) { } function chooseBuilding(ctx, mine) { - const { rules, army, mem, expansion } = ctx; + const { rules, army, mem, expansion, skill } = ctx; const n = (id) => (mine.byDef[id] ?? 0); const eGen = rules.buildingById.energygen; const mGen = rules.buildingById.massgen; @@ -180,6 +183,19 @@ function chooseBuilding(ctx, mine) { if (n('massgen') < 2) return mGen; if (n('vehicleplant') < 1) return plant; + // Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the + // bottom of the ladder still runs the same ratio-aware economy as the top and plays a + // perfectly respectable game, which is why difficulty barely registered. + if (skill <= 2) { + const lim = skill === 1 ? 1 : 2; + if (n('energygen') < 3 + lim) return eGen; + if (n('massgen') < 2 + lim) return mGen; + if (n('barracks') + n('vehicleplant') < 2 + lim) { + return n('vehicleplant') <= n('barracks') ? plant : barracks; + } + return null; + } + // Steady state: chase the energy:mass income RATIO that the unit roster actually costs. // A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources // symmetrically ends up capping out on energy while its factories starve for mass. @@ -196,19 +212,33 @@ function chooseBuilding(ctx, mine) { if (army.stallM < 0.98 && roomM) return mGen; if (army.stallE < 0.98 && roomE) return eGen; + + // Production capacity is grown ALONGSIDE the economy, sized to what the income can + // actually feed. Saturating generators first (the obvious ordering) is why a big economy + // used to win nothing: with only two factories the surplus just floated in storage, and an + // army that out-earned its opponent two to one still fielded the same number of tanks. + const factories = n('barracks') + n('vehicleplant'); + const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks); + const factoryCap = Math.round(2 + expansion * 4); + const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant))); + if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory(); + if (ratio > TARGET_RATIO && roomM) return mGen; if (ratio < TARGET_RATIO && roomE) return eGen; if (roomM) return mGen; if (roomE) return eGen; - - // Economy is built out — convert the surplus into production capacity. - const factories = n('barracks') + n('vehicleplant'); - if (factories < Math.round(2 + expansion * 3)) { - return n('vehicleplant') <= n('barracks') ? plant : barracks; - } + if (factories < factoryCap) return nextFactory(); return null; } +/** Mass per second a factory of this type burns when kept busy. */ +function factoryMassDrain(rules, def) { + const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean); + if (!units.length) return 1; + const avg = units.reduce((s, u) => s + u.cost.mass / u.buildTime, 0) / units.length; + return avg * (def.buildPower / rules.constants.buildPowerNominal); +} + /** * Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for * a metal patch, which is what makes map control matter without a reclaim economy. @@ -301,8 +331,8 @@ function randomPick(ctx, builds) { /** Pick whichever buildable unit is furthest below its share of the desired mix. */ function templatePick(ctx, mine, builds) { - const { rules, skill } = ctx; - const weights = { ...COMPOSITION }; + const { rules, skill, profile } = ctx; + const weights = { ...COMPOSITION, ...(profile.composition ?? {}) }; if (skill >= 4) applyCounters(ctx, weights); const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1; @@ -424,8 +454,8 @@ function manageMilitary(ctx, mine) { mem.squad = mem.squad.filter((id) => state.entities.some((e) => e.id === id && !e.dead)); for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id); - // Home defence always wins over pushing out. - const threat = nearestThreatToBase(ctx); + // Home defence always wins over pushing out — skill 1 never thinks to come home. + const threat = skill >= 2 ? nearestThreatToBase(ctx) : null; if (threat) { const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7))); if (defenders.length) { @@ -507,27 +537,40 @@ function focusFire(ctx, mine) { .filter(Boolean); if (members.length < 3) return; - const cx = members.reduce((s, u) => s + u.x, 0) / members.length; - const cy = members.reduce((s, u) => s + u.y, 0) / members.length; + // An `attack` order REPLACES the unit's queue and pins its target, so re-picking one every + // think interrupts the push and leaves the squad ignoring whatever is actually shooting it. + // Focus fire is therefore sticky: hold the current victim until it dies or gets out of + // reach, and only ever commit to something the squad can already shoot without moving. + const focus = state.entities.find((e) => e.id === mem.focusId && !e.dead); + if (focus && isVisibleTo(state, armyIdx, focus)) { + const stillOn = members.filter((u) => inReach(rules, u, focus)).length; + if (stillOn >= 2) return; // current focus is fine; don't churn orders + } + mem.focusId = 0; - let best = null, bestScore = -Infinity; + let best = null, bestScore = -Infinity, bestShooters = null; for (const e of state.entities) { if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue; if (!isVisibleTo(state, armyIdx, e)) continue; - const d = Math.hypot(e.x - cx, e.y - cy); - if (d > state.tileSize * 9) continue; + const shooters = members.filter((u) => inReach(rules, u, e)); + if (shooters.length < Math.max(2, Math.ceil(members.length * 0.4))) continue; const def = rules.defById[e.defId]; // Prefer the closest thing to dying that can still shoot back. - const score = (def.weaponDefs?.length ? 2 : 1) * (1 - e.hp / e.maxHp + 0.3) * (1 - d / (state.tileSize * 9)); - if (score > bestScore) { best = e; bestScore = score; } + const score = (def.weaponDefs?.length ? 2 : 1) * (1.3 - e.hp / e.maxHp) * shooters.length; + if (score > bestScore) { best = e; bestScore = score; bestShooters = shooters; } } if (!best) return; - const shooters = members.filter((u) => Math.hypot(u.x - best.x, u.y - best.y) < rules.defById[u.defId].maxRange * 1.5); - if (shooters.length < 2) return; + mem.focusId = best.id; order(ctx, { - army: armyIdx, unitIds: shooters.map((u) => u.id), + army: armyIdx, unitIds: bestShooters.map((u) => u.id), order: { type: 'attack', targetId: best.id }, }); } +/** True when `u` could fire on `t` from where it already stands. */ +function inReach(rules, u, t) { + const def = rules.defById[u.defId]; + return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange; +} + export { memFor }; diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index 689af98..3bd09da 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -32,7 +32,18 @@ function frameRng(seed) { * Create a canvas texture laid out as a spritesheet and register its frames. * `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads. */ -function mkCanvasSheet(scene, key, frameW, frameH, cols, count) { +function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) { + // Guard every dimension. A NaN anywhere here reaches createCanvas as a zero-height texture + // and surfaces as an opaque WebGL "no canvas" error a long way from the actual mistake. + const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback); + const frameW = size(wantW, 64); + const frameH = size(wantH, 64); + const cols = size(wantCols, 8); + const count = size(wantCount, 1); + if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) { + console.warn(`[TAArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, ` + + `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`); + } const rows = Math.max(1, Math.ceil(count / cols)); const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH); const ctx = tex.getContext(); @@ -361,7 +372,11 @@ const PROC_PAINTERS = { const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName); const p = theme?.palette ?? {}; const F = art.terrainFrames ?? {}; - const count = Math.max(9, ...Object.values(F).map((v) => v + 1)); + // The JSON carries a `_comment` key alongside the frame numbers, so filter to actual + // numbers — one string in this list turns the max into NaN and the sheet into a + // zero-height canvas. + const frameNums = Object.values(F).filter((v) => Number.isFinite(v)); + const count = Math.max(9, ...frameNums.map((v) => v + 1)); const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count); const speckle = (ctx, S, seed, color, n, r0, r1) => { diff --git a/src/games/totalannihilation/TAFx.js b/src/games/totalannihilation/TAFx.js index ea21c2f..91598df 100644 --- a/src/games/totalannihilation/TAFx.js +++ b/src/games/totalannihilation/TAFx.js @@ -14,6 +14,8 @@ export default class TAFx { // Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts. this.gUnder = scene.add.graphics().setDepth(depths.fxUnder); this.gOver = scene.add.graphics().setDepth(depths.fxOver); + // Depth on a Container child does nothing until the container is sorted; TAWorldView + // re-sorts the world container every frame, which is what puts these layers in place. worldRoot.add(this.gUnder); worldRoot.add(this.gOver); this.list = []; diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js new file mode 100644 index 0000000..a3b5726 --- /dev/null +++ b/src/games/totalannihilation/TAHud.js @@ -0,0 +1,398 @@ +// Total Annihilation — screen-space HUD. +// +// Everything here lives on `view.uiRoot` so the UI camera draws it and the world camera +// ignores it (TAWorldView._setupCameras). The HUD owns no game rules: it reads state and +// calls back into the scene, which is the only thing allowed to issue orders. +// +// The resource strip is the important piece. A flow economy is unreadable without showing +// the RATES and the stall factor — "1200 mass" tells a player nothing, "+18 −24, STALL 74%" +// tells them to build another Mass Generator. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { canAfford, buildOptionsFor } from './TALogic.js'; +import { Tooltip } from '../../ui/Tooltip.js'; + +export const FONT = 'm6x11, "Julius Sans One"'; + +const PANEL = 0x141a24; +const EDGE = 0x39465e; +const TEXT = '#e8eef7'; +const DIM = '#8fa0b8'; + +const BAR_H = 62; // top resource strip +const BOT_H = 190; // bottom command bar +const MINI = 176; // minimap edge length +const BTN_W = 88, BTN_H = 74; // build-menu button size + +/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */ +function shortName(name) { + const words = name.split(' '); + if (words.length > 1) return words.map((w) => w[0]).join('').toUpperCase(); + return name.length > 8 ? name.slice(0, 8) : name; +} + +export default class TAHud { + constructor(scene, rules, state, view, playerArmy, handlers) { + this.scene = scene; + this.rules = rules; + this.state = state; + this.view = view; + this.playerArmy = playerArmy; + this.h = handlers; // { onBuildPick, onProduce, onCommand, onMinimapJump } + this.buildButtons = []; + this.tooltip = new Tooltip(scene); + this.root = scene.add.container(0, 0).setDepth(1000); + view.uiRoot.add(this.root); + + this._buildTopStrip(); + this._buildBottomBar(); + this._buildMinimap(); + this._pulse = 0; + } + + /** Screen rects the camera must NOT pan from — clicks here are UI, not world orders. */ + get blockRects() { + return [ + new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, BAR_H), + new Phaser.Geom.Rectangle(0, GAME_HEIGHT - BOT_H, GAME_WIDTH, BOT_H), + ]; + } + + hitsUi(x, y) { + return this.blockRects.some((r) => Phaser.Geom.Rectangle.Contains(r, x, y)); + } + + // ------------------------------------------------------------------------- + // Top strip: MASS and ENERGY as stored/cap plus flow rates + // ------------------------------------------------------------------------- + + _buildTopStrip() { + const s = this.scene; + const bg = s.add.rectangle(0, 0, GAME_WIDTH, BAR_H, PANEL, 0.94).setOrigin(0, 0); + bg.setStrokeStyle(2, EDGE); + this.root.add(bg); + + this.res = {}; + const mk = (key, label, x, color) => { + const g = s.add.graphics(); + const name = s.add.text(x, 10, label, { fontFamily: FONT, fontSize: '20px', color: DIM }); + const amount = s.add.text(x, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT }); + const flow = s.add.text(x + 300, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT }); + this.root.add([g, name, amount, flow]); + this.res[key] = { g, name, amount, flow, x, color }; + }; + mk('mass', 'MASS', 28, 0x9fb4cf); + mk('energy', 'ENERGY', GAME_WIDTH / 2 - 240, 0xf2c744); + + this.stallText = s.add.text(GAME_WIDTH - 28, 20, '', { + fontFamily: FONT, fontSize: '26px', color: '#ff6b5a', + }).setOrigin(1, 0); + this.clock = s.add.text(GAME_WIDTH - 28, 4, '', { + fontFamily: FONT, fontSize: '20px', color: DIM, + }).setOrigin(1, 0); + this.root.add([this.stallText, this.clock]); + } + + _drawResource(key, stored, cap, income, drain, stall) { + const r = this.res[key]; + const w = 260, h = 12, y = 16; + r.g.clear(); + r.g.fillStyle(0x0b0e14, 1); + r.g.fillRect(r.x + 84, y, w, h); + const frac = cap > 0 ? Math.max(0, Math.min(1, stored / cap)) : 0; + r.g.fillStyle(r.color, 1); + r.g.fillRect(r.x + 84, y, w * frac, h); + r.g.lineStyle(1, EDGE, 1); + r.g.strokeRect(r.x + 84, y, w, h); + r.amount.setText(`${Math.round(stored)} / ${Math.round(cap)}`); + const net = income - drain; + r.flow.setText(`+${income.toFixed(1)} -${drain.toFixed(1)}`); + r.flow.setColor(net < -0.05 ? '#ff9a6b' : '#9ce6a0'); + // Empty and still draining is the state the player must notice. + const critical = stall < 0.98 && stored < cap * 0.02; + r.amount.setColor(critical ? '#ff6b5a' : TEXT); + } + + // ------------------------------------------------------------------------- + // Bottom bar: selection roster, build/production grid, command buttons + // ------------------------------------------------------------------------- + + _buildBottomBar() { + const s = this.scene; + const y = GAME_HEIGHT - BOT_H; + const bg = s.add.rectangle(0, y, GAME_WIDTH, BOT_H, PANEL, 0.94).setOrigin(0, 0); + bg.setStrokeStyle(2, EDGE); + this.root.add(bg); + + this.selTitle = s.add.text(MINI + 60, y + 12, '', { + fontFamily: FONT, fontSize: '26px', color: TEXT, + }); + this.selDetail = s.add.text(MINI + 60, y + 44, '', { + fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3, + }); + this.queueText = s.add.text(MINI + 60, y + 120, '', { + fontFamily: FONT, fontSize: '19px', color: '#9ce6a0', + }); + this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', { + fontFamily: FONT, fontSize: '17px', color: DIM, + }).setOrigin(1, 0.5); + this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]); + + this.gridX = GAME_WIDTH - 24 - 6 * 96; + this.gridY = y + 16; + } + + _clearGrid() { + for (const b of this.buildButtons) b.destroy(); + this.buildButtons.length = 0; + } + + /** + * Repaint the build/production grid for the current selection. Buttons carry the def so + * the scene can tell "start placing a building" from "enqueue a unit" without re-deriving. + */ + _drawGrid(options, kind) { + this._clearGrid(); + const s = this.scene; + const army = this.state.armies[this.playerArmy]; + options.forEach((def, i) => { + const col = i % 6, row = Math.floor(i / 6); + // The container is placed at the button's CENTRE, not its top-left — see below. + const x = this.gridX + col * 96 + BTN_W / 2; + const y = this.gridY + row * 82 + BTN_H / 2; + const affordable = canAfford(this.state, this.playerArmy, def); + const c = s.add.container(x, y); + // A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive() + // always builds a hit area CENTRED on the container's position. Children drawn from a + // top-left origin therefore sit half a button down-right of their own hitbox. Every + // child here is centred on (0,0) so the visual and the hit area coincide. + const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1) + .setStrokeStyle(2, affordable ? EDGE : 0x2a3244); + const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), { + fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80', + }).setOrigin(0.5, 0); + const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, { + fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d', + }).setOrigin(0.5, 0); + c.add([box, label, cost]); + c.setSize(BTN_W, BTN_H); + c.setInteractive({ useHandCursor: true }); + c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1)); + c.on('pointerout', () => box.setFillStyle(affordable ? 0x22304a : 0x1a2130, 1)); + this.tooltip.attachTo(c, () => this._defTip(def)); + c.on('pointerdown', (p, lx, ly, ev) => { + ev?.stopPropagation(); + if (kind === 'build') this.h.onBuildPick(def); + else this.h.onProduce(def, p.event?.shiftKey ? 5 : 1); + }); + this.root.add(c); + this.buildButtons.push(c); + }); + } + + _defTip(def) { + const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`]; + if (def.hp) lines.push(`HP ${def.hp}`); + if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`); + for (const w of def.weaponDefs ?? []) { + lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`); + } + if (def.makes) { + const m = []; + if (def.makes.mass) m.push(`+${def.makes.mass} mass/s`); + if (def.makes.energy) m.push(`+${def.makes.energy} energy/s`); + if (m.length) lines.push(m.join(' ')); + } + if (def.upkeep?.energy) lines.push(`Upkeep ${def.upkeep.energy} energy/s`); + if (def.terrainMultiplier) lines.push('Build on a metal patch for double yield'); + if (def.builds?.length) lines.push(`Builds: ${def.builds.join(', ')}`); + return { title: def.name, lines: lines.map((text) => ({ text })) }; + } + + // ------------------------------------------------------------------------- + // Minimap — a canvas texture rebaked a few times a second, dots stroked live + // ------------------------------------------------------------------------- + + _buildMinimap() { + const s = this.scene; + const x = 20, y = GAME_HEIGHT - BOT_H + 8; + this.miniX = x; this.miniY = y; + const key = `ta-minimap-${s.scene.key}`; + if (s.textures.exists(key)) s.textures.remove(key); + this.miniTex = s.textures.createCanvas(key, this.state.w, this.state.h); + this.miniImg = s.add.image(x, y, key).setOrigin(0, 0) + .setDisplaySize(MINI, MINI).setInteractive({ useHandCursor: true }); + this.miniImg.texture.setFilter(Phaser.Textures.FilterMode.NEAREST); + this.miniG = s.add.graphics(); + this.root.add([this.miniImg, this.miniG]); + + const jump = (p) => { + const fx = Phaser.Math.Clamp((p.x - x) / MINI, 0, 1); + const fy = Phaser.Math.Clamp((p.y - y) / MINI, 0, 1); + this.h.onMinimapJump(fx * this.state.worldW, fy * this.state.worldH); + }; + this.miniImg.on('pointerdown', (p) => { this._miniDrag = true; jump(p); }); + s.input.on('pointerup', () => { this._miniDrag = false; }); + s.input.on('pointermove', (p) => { if (this._miniDrag) jump(p); }); + this._miniBakedAt = -1e9; + } + + _bakeMinimap(timeMs) { + if (timeMs - this._miniBakedAt < 250) return; + this._miniBakedAt = timeMs; + const st = this.state, ctx = this.miniTex.context; + const img = ctx.createImageData(st.w, st.h); + const pal = this.rules.terrain.map((t) => { + const hex = this.view.themePalette?.[t.id] ?? '#404040'; + const c = parseInt(String(hex).slice(1), 16); + return [(c >> 16) & 255, (c >> 8) & 255, c & 255]; + }); + const army = st.armies[this.playerArmy]; + for (let ty = 0; ty < st.h; ty++) { + for (let tx = 0; tx < st.w; tx++) { + const i = ty * st.w + tx; + const [r, g, b] = pal[st.terrain[i]] ?? [64, 64, 64]; + // The vision grid is half tile resolution, and unexplored must read as black: + // a minimap that ignores fog is just a wallhack with extra steps. + const v = (ty >> 1) * st.visW + (tx >> 1); + const k = army.explored[v] ? (army.visible[v] ? 1 : 0.45) : 0; + const o = i * 4; + img.data[o] = r * k; img.data[o + 1] = g * k; img.data[o + 2] = b * k; img.data[o + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + this.miniTex.refresh(); + } + + _drawMinimapOverlay() { + const g = this.miniG, st = this.state; + g.clear(); + const sx = MINI / st.worldW, sy = MINI / st.worldH; + for (const e of st.entities) { + if (e.dead) continue; + if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; + const a = this.rules.armies[e.army]; + g.fillStyle(a ? a.colorInt : 0x888888, 1); + const r = e.isBuilding ? 2.5 : 1.6; + g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2); + } + const cam = this.scene.cameras.main; + g.lineStyle(1.5, 0xffffff, 0.8); + g.strokeRect( + this.miniX + cam.worldView.x * sx, this.miniY + cam.worldView.y * sy, + cam.worldView.width * sx, cam.worldView.height * sy, + ); + } + + // ------------------------------------------------------------------------- + // Per-frame update + // ------------------------------------------------------------------------- + + update(timeMs, selection, placementDef) { + const st = this.state, army = st.armies[this.playerArmy]; + if (!army) return; + + this._drawResource('mass', army.mass, army.massCap, army.mIncome, army.mDrain * army.stallM, army.stallM); + this._drawResource('energy', army.energy, army.energyCap, army.eIncome, (army.eDrain * army.stallE) + army.eUpkeep * army.upkeepFactor, army.stallE); + + const eff = army.buildEff ?? 1; + if (eff < 0.98) { + this._pulse = (this._pulse + 0.12) % (Math.PI * 2); + this.stallText.setText(`STALL ${Math.round(eff * 100)}%`); + this.stallText.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this._pulse))); + } else { + this.stallText.setText(''); + } + const t = Math.floor(st.elapsedSec); + this.clock.setText(`${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`); + + this._bakeMinimap(timeMs); + this._drawMinimapOverlay(); + this._refreshSelection(selection, placementDef); + } + + _refreshSelection(selection, placementDef) { + const sel = [...selection]; + const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '') + + '|' + sel.map((e) => e.queue?.length ?? 0).join(',') + + '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25); + if (sig === this._selSig) { this._refreshQueue(sel); return; } + this._selSig = sig; + + if (!sel.length) { + this.selTitle.setText(''); + this.selDetail.setText(''); + this.queueText.setText(''); + this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups'); + this._clearGrid(); + return; + } + + const counts = new Map(); + for (const e of sel) { + const def = this.rules.defById[e.defId]; + counts.set(def.name, (counts.get(def.name) ?? 0) + 1); + } + this.selTitle.setText([...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' ')); + + const lead = sel[0]; + const def = this.rules.defById[lead.defId]; + const lines = []; + if (sel.length === 1) { + lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`); + if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`); + if (!lead.site && lead.hasRally) lines.push('Rally point set'); + for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`); + } else { + const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0); + lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`); + } + this.selDetail.setText(lines.join('\n')); + + if (placementDef) { + this.hint.setText(`Placing ${placementDef.name} — LMB to site it, Esc to cancel`); + } else { + this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups'); + } + + // Grid shows the builder's structures, or the factory's units — never both. + const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + if (builder) this._drawGrid(buildOptionsFor(this.rules, builder), 'build'); + else if (factory) this._drawGrid(buildOptionsFor(this.rules, factory), 'produce'); + else this._clearGrid(); + } + + _refreshQueue(sel) { + const f = sel.find((e) => e.isBuilding && !e.site && e.queue?.length); + if (!f) { this.queueText.setText(''); return; } + const parts = f.queue.map((q, i) => { + const name = this.rules.defById[q.defId].name; + const pct = i === 0 ? ` ${Math.round((f.jobProgress ?? 0) * 100)}%` : ''; + return `${name}${q.count > 1 ? ` ×${q.count}` : ''}${pct}`; + }); + this.queueText.setText(`QUEUE ${parts.join(' | ')}`); + } + + toast(msg, color = '#ffd27a') { + if (this._toast) this._toast.destroy(); + this._toast = this.scene.add.text(GAME_WIDTH / 2, BAR_H + 40, msg, { + fontFamily: FONT, fontSize: '28px', color, + }).setOrigin(0.5, 0); + this.view.uiRoot.add(this._toast); + this.scene.tweens.add({ + targets: this._toast, alpha: 0, delay: 1400, duration: 600, + onComplete: () => { this._toast?.destroy(); this._toast = null; }, + }); + } + + destroy() { + this._clearGrid(); + this.tooltip?.destroy(); + this._toast?.destroy(); + this.miniG?.destroy(); + this.miniImg?.destroy(); + this.root.destroy(true); + } +} diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 2d7a102..57f515f 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -931,6 +931,8 @@ function stepCombat(state, rules) { for (let wi = 0; wi < def.weaponDefs.length; wi++) { const w = def.weaponDefs[wi]; if (e.reload[wi] > 0) continue; + // Manual weapons (the D-Gun) only fire when this exact target was ordered attacked. + if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue; const dist = Math.hypot(dx, dy) - target.radius; if (dist > w.range || dist < (w.minRange ?? 0)) continue; if (!aligned) continue; @@ -949,6 +951,7 @@ function stepCombat(state, rules) { // Continue an in-flight burst. for (let wi = 0; wi < def.weaponDefs.length; wi++) { const w = def.weaponDefs[wi]; + if (w.manual) continue; if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) { fireWeapon(state, rules, e, w, target, facing); e.burstLeft[wi]--; @@ -1083,16 +1086,30 @@ function stepProjectiles(state, rules) { if (--p.ttl <= 0) detonate = true; if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true; - // Proximity to the aim point (ballistic) or the live target (guided). + // Collision is SWEPT along this tick's segment, not sampled at the new position. A shell + // travels 35px in one 20 Hz tick — well past a 12px hit window and past most unit radii — + // so a point test made tank fire pass clean through whatever it was aimed at. if (!detonate) { const hitR = w.kind === 'guided' ? 14 : 12; - if (Math.hypot(p.aimX - p.x, p.aimY - p.y) <= hitR) detonate = true; + if (segDistSq(p.px, p.py, p.x, p.y, p.aimX, p.aimY) <= hitR * hitR) { + detonate = true; + // Detonate where the shell actually met the aim point, not where the step ended. + const s = segClosestT(p.px, p.py, p.x, p.y, p.aimX, p.aimY); + p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s; + } } // Direct contact with any hostile it passes through. if (!detonate) { + let bestT = Infinity, hit = null; for (const e of state.entities) { if (e.dead || e.site || e.army === p.army) continue; - if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { detonate = true; break; } + if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue; + const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y); + if (s < bestT) { bestT = s; hit = e; } + } + if (hit) { + detonate = true; + p.x = p.px + (p.x - p.px) * bestT; p.y = p.py + (p.y - p.py) * bestT; } } @@ -1115,6 +1132,22 @@ function stepProjectiles(state, rules) { state.projectiles = keep; } +/** Parametric position along segment AB of the point on it closest to P, clamped to [0,1]. */ +function segClosestT(ax, ay, bx, by, px, py) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (len2 <= 1e-9) return 0; + const t = ((px - ax) * dx + (py - ay) * dy) / len2; + return t < 0 ? 0 : t > 1 ? 1 : t; +} + +/** Squared distance from P to segment AB. */ +function segDistSq(ax, ay, bx, by, px, py) { + const t = segClosestT(ax, ay, bx, by, px, py); + const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py; + return cx * cx + cy * cy; +} + function applyAoe(state, rules, x, y, w, source) { const r = w.aoe; const falloff = w.aoeFalloff ?? 0.4; @@ -1124,7 +1157,10 @@ function applyAoe(state, rules, x, y, w, source) { const d = Math.hypot(e.x - x, e.y - y) - e.radius; if (d > r) continue; const t = Math.max(0, Math.min(1, d / r)); - const scale = 1 - (1 - falloff) * t; + // Quadratic falloff, not linear. Linear left near-full damage on every neighbour of the + // unit actually hit, so one shell wiped a whole packed formation and splash weapons beat + // everything else on cost regardless of what they were pointed at. + const scale = falloff + (1 - falloff) * (1 - t) * (1 - t); const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1; applyDamage(state, rules, e, w.damage * mul * scale, source); } @@ -1218,21 +1254,27 @@ export function isVisibleTo(state, armyIdx, e) { function checkResult(state, rules) { if (state.over) return; - const commanderEnds = rules.constants.commanderDeathEnds !== false; + const unrecoverableEnds = rules.constants.eliminateWhenUnrecoverable !== false; for (const a of state.armies) { if (!a.alive) continue; - let hasAny = false, hasBuilder = false; + let hasAny = false, hasBuilder = false, hasFactory = false; for (const e of state.entities) { if (e.dead || e.army !== a.idx || e.site) continue; hasAny = true; const def = rules.defById[e.defId]; - if (!e.isBuilding && (def.builds ?? []).length) hasBuilder = true; + if (!(def.builds ?? []).length) continue; + if (e.isBuilding) hasFactory = true; else hasBuilder = true; } - // TA's "Commander Ends" rule. Without it a match can sit at the time limit while the - // loser hides one Mass Generator in a corner with no way to ever build again. - if (!hasAny || (commanderEnds && !hasBuilder)) { + // Elimination is "can never produce again", not "lost the Commander". Ending on Commander + // death made a single lucky raid decide matches, which flattened the skill ladder — economy + // and army quality never got to matter. An army that still owns a factory can rebuild, so it + // stays in; one with neither a mobile builder nor a factory is only running down the clock. + const unrecoverable = unrecoverableEnds && !hasBuilder && !hasFactory; + if (!hasAny || unrecoverable) { a.alive = false; - state.events.push({ t: 'armyEliminated', army: a.idx, reason: hasAny ? 'commander' : 'wiped' }); + state.events.push({ + t: 'armyEliminated', army: a.idx, reason: hasAny ? 'unrecoverable' : 'wiped', + }); } } const alive = state.armies.filter((a) => a.alive); @@ -1277,12 +1319,17 @@ export function tick(state, rules) { * in state so the verify script runs the identical loop headlessly, and `state.alpha` gives * the view its interpolation factor between the last two ticks. */ -export function step(state, rules, deltaMs, maxSteps = 4) { +/** + * @param {function} [onTick] runs just BEFORE each fixed step — where the AI belongs, so its + * think cadence is measured in sim ticks rather than riding on the render framerate. + */ +export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) { const out = []; state.accumulatorMs += deltaMs; let n = 0; while (state.accumulatorMs >= rules.stepMs && n < maxSteps) { state.accumulatorMs -= rules.stepMs; + if (onTick) onTick(state); const ev = tick(state, rules); for (let i = 0; i < ev.length; i++) out.push(ev[i]); n++; diff --git a/src/games/totalannihilation/TANav.js b/src/games/totalannihilation/TANav.js index bda0056..956eb16 100644 --- a/src/games/totalannihilation/TANav.js +++ b/src/games/totalannihilation/TANav.js @@ -82,6 +82,7 @@ export function refreshRegion(nav, rules, x0, y0, x1, y1) { // affect tiles up-left of it — expand the rect that way and sweep in reverse. const cx0 = Math.max(0, x0 - MAX_CLEARANCE); const cy0 = Math.max(0, y0 - MAX_CLEARANCE); + nav._fit = null; // clearance moved, so every dilated fit mask is stale for (const mc of nav.moveClassIds) { const pass = nav.passable[mc]; const cl = nav.clearance[mc]; @@ -123,10 +124,50 @@ export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2; export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2; +/** + * Per-tile "a unit this wide can stand here" mask, cached per move class and need. + * + * clearance[i] is the largest all-passable square whose TOP-LEFT corner is tile i, so + * reading it directly asks "does the square start here" — which wrongly rejects every tile + * along the bottom and right edge of an open area, even though a unit centred there fits + * perfectly well. A tile is genuinely usable when ANY anchor covering it qualifies, so this + * dilates the clearance test by the need window (a separable two-pass OR). + */ +export function fitField(nav, mc, need) { + if (need <= 1) return nav.clearance[mc]; + const key = `${mc}:${need}`; + if (!nav._fit) nav._fit = Object.create(null); + const hit = nav._fit[key]; + if (hit) return hit; + + const { w, h } = nav; + const cl = nav.clearance[mc]; + const rowPass = new Uint8Array(w * h); + for (let y = 0; y < h; y++) { + let run = 0; // how many of the last `need` tiles to the left qualify as anchors + for (let x = 0; x < w; x++) { + const i = y * w + x; + run = cl[i] >= need ? need : (run > 0 ? run - 1 : 0); + rowPass[i] = run > 0 ? 1 : 0; + } + } + const out = new Uint8Array(w * h); + for (let x = 0; x < w; x++) { + let run = 0; + for (let y = 0; y < h; y++) { + const i = y * w + x; + run = rowPass[i] ? need : (run > 0 ? run - 1 : 0); + out[i] = run > 0 ? need : 0; + } + } + nav._fit[key] = out; + return out; +} + /** Is this tile usable by a unit with the given move class and clearance need? */ export function tileOk(nav, mc, need, tx, ty) { if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false; - return nav.clearance[mc][ty * nav.w + tx] >= need; + return fitField(nav, mc, need)[ty * nav.w + tx] >= need; } /** @@ -216,7 +257,7 @@ const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1]; */ export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) { if (startIdx === goalIdx) return Int32Array.of(startIdx); - const cl = nav.clearance[mc]; + const cl = fitField(nav, mc, need); const cost = nav.cost[mc]; if (cl[goalIdx] < need || cl[startIdx] < need) return null; @@ -297,7 +338,7 @@ function reconstruct(came, startIdx, goalIdx) { */ export function segmentClear(nav, mc, need, x0, y0, x1, y1) { const ts = nav.tileSize; - const cl = nav.clearance[mc]; + const cl = fitField(nav, mc, need); let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts); const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts); const dx = x1 - x0, dy = y1 - y0; diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 21d0aa6..97d57e6 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -42,6 +42,16 @@ function requirePositive(obj, keys, what) { } } +/** + * Acquisition range. Manual weapons are excluded so a Commander doesn't chase enemies into + * D-Gun range it will never voluntarily use; a manual-only def falls back to its full reach + * so it still knows when something is worth shooting at. + */ +function autoRangeOf(weaponDefs) { + const auto = weaponDefs.filter((w) => !w.manual); + return (auto.length ? auto : weaponDefs).reduce((m, w) => Math.max(m, w.range), 0); +} + function indexById(list) { const m = Object.create(null); for (const e of list) m[e.id] = e; @@ -163,6 +173,10 @@ export function compileRules(json) { w.rangeSq = w.range * w.range; w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0); w.targetsAir = (w.targets ?? ['ground']).includes('air'); + // A manual weapon never auto-fires: it needs an explicit attack order on the victim. + // The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander + // deletes one attacker every reload for free, which makes assaulting any base suicide. + w.manual = w.manual === true; } const weaponById = indexById(weapons); @@ -193,7 +207,7 @@ export function compileRules(json) { u.sightSq = u.sight * u.sight; u.isBuilding = false; u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]); - u.maxRange = u.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0); + u.maxRange = autoRangeOf(u.weaponDefs); u.spritePx = u.spritePx ?? sc.radius * 2; buildable[u.id] = u; } @@ -221,7 +235,7 @@ export function compileRules(json) { b.sight = b.sight ?? 0; b.sightSq = b.sight * b.sight; b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]); - b.maxRange = b.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0); + b.maxRange = autoRangeOf(b.weaponDefs); buildable[b.id] = b; } const buildingById = indexById(buildings); diff --git a/src/games/totalannihilation/TAScreens.js b/src/games/totalannihilation/TAScreens.js new file mode 100644 index 0000000..353143e --- /dev/null +++ b/src/games/totalannihilation/TAScreens.js @@ -0,0 +1,295 @@ +// Total Annihilation — front-end screens. +// +// Each builder returns a plain `{ destroy() }` handle and the scene keeps exactly one alive +// (TotalAnnihilationGame.swapScreen), which is the AdvanceWarsScreens.js router pattern. +// Screens never touch the simulation: they collect a choice and hand it back through a +// callback, so the same match can be started from a menu, a save, or the verify script. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { createOpponentPortrait } from '../../ui/Portrait.js'; +import { FONT } from './TAHud.js'; + +const BG = 0x0b0f16; +const PANEL = 0x161d29; +const EDGE = 0x39465e; +const ACCENT = 0x7dc4ff; +const TEXT = '#e8eef7'; +const DIM = '#93a3ba'; + +function screen(scene, objs) { + return { + objs, + destroy() { for (const o of objs) o.destroy?.(); objs.length = 0; }, + }; +} + +function backdrop(scene, objs, title, subtitle) { + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900) + .setInteractive()); // swallow clicks so they never reach the world beneath + const t = scene.add.text(GAME_WIDTH / 2, 92, title, { + fontFamily: FONT, fontSize: '76px', color: TEXT, + }).setOrigin(0.5).setDepth(901); + objs.push(t); + if (subtitle) { + objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, { + fontFamily: FONT, fontSize: '24px', color: DIM, + }).setOrigin(0.5).setDepth(901)); + } + return t; +} + +function button(scene, objs, x, y, w, h, label, onClick, { enabled = true, size = 28 } = {}) { + const c = scene.add.container(x, y).setDepth(902); + const bg = scene.add.rectangle(0, 0, w, h, enabled ? 0x22304a : 0x1a2130, 1) + .setStrokeStyle(2, enabled ? EDGE : 0x2a3244); + const txt = scene.add.text(0, 0, label, { + fontFamily: FONT, fontSize: `${size}px`, color: enabled ? TEXT : '#5d6b80', + }).setOrigin(0.5); + c.add([bg, txt]); + // A Container's origin is fixed at 0.5 (read-only in Phaser), so setInteractive() always + // produces a hit area centred on the container's position. Both children above are drawn + // centred on (0,0) to match; a child with a top-left origin would render half a button + // down-right of the area that actually receives the click. + c.setSize(w, h); + if (enabled) { + c.setInteractive({ useHandCursor: true }); + c.on('pointerover', () => bg.setFillStyle(0x2e3f5f, 1)); + c.on('pointerout', () => bg.setFillStyle(0x22304a, 1)); + c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); }); + } + objs.push(c); + c.label = txt; c.bg = bg; + return c; +} + +/** A labelled left/right cycler over a list of options. */ +function cycler(scene, objs, x, y, label, options, index, onChange) { + objs.push(scene.add.text(x, y - 30, label, { + fontFamily: FONT, fontSize: '20px', color: DIM, + }).setOrigin(0, 0.5).setDepth(902)); + const value = scene.add.text(x + 150, y, String(options[index].label), { + fontFamily: FONT, fontSize: '28px', color: TEXT, + }).setOrigin(0.5).setDepth(902); + objs.push(value); + let i = index; + const step = (d) => { + i = (i + d + options.length) % options.length; + value.setText(String(options[i].label)); + onChange(options[i].value, i); + }; + button(scene, objs, x + 26, y, 40, 40, '<', () => step(-1), { size: 22 }); + button(scene, objs, x + 274, y, 40, 40, '>', () => step(1), { size: 22 }); + return { get value() { return options[i].value; } }; +} + +// --------------------------------------------------------------------------- +// Main menu +// --------------------------------------------------------------------------- + +export function mainMenu(scene, { hasSave, onSkirmish, onCampaign, onContinue, onLeave }) { + const objs = []; + backdrop(scene, objs, 'TOTAL ANNIHILATION', 'Commander — your war machine awaits'); + + const cx = GAME_WIDTH / 2; + let y = 330; + button(scene, objs, cx, y, 460, 76, 'SKIRMISH', onSkirmish); y += 100; + button(scene, objs, cx, y, 460, 76, 'CAMPAIGN', onCampaign); y += 100; + button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasSave }); y += 100; + button(scene, objs, cx, y, 460, 76, 'BACK TO ARCADE', onLeave); + + objs.push(scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 56, + 'Build power and mass, field an army, and leave nothing standing.', { + fontFamily: FONT, fontSize: '20px', color: DIM, + }).setOrigin(0.5).setDepth(901)); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Skirmish setup +// --------------------------------------------------------------------------- + +export function skirmishSetup(scene, { onBack, onStart }) { + const objs = []; + const rules = scene.rules; + backdrop(scene, objs, 'SKIRMISH', 'Generated map — every seed is a new battlefield'); + + const sk = rules.skirmish ?? {}; + const sizes = Object.keys(sk.sizes ?? { small: 64, medium: 96, large: 128 }) + .map((k) => ({ label: `${k.toUpperCase()} ${sk.sizes?.[k] ?? ''}`.trim(), value: k })); + const themes = Object.entries(scene.art.themes ?? {}).map(([id, t]) => ({ label: (t.name ?? id).toUpperCase(), value: id })); + const syms = (sk.symmetries ?? ['mirror-x', 'mirror-y', 'rotational']) + .map((s) => ({ label: s.replace('-', ' ').toUpperCase(), value: s })); + const skills = [1, 2, 3, 4, 5].map((n) => ({ label: `SKILL ${n}`, value: n })); + + const cfg = { + seed: (Math.random() * 0xffffffff) >>> 0, + size: sizes[0].value, + theme: themes[0]?.value ?? 'grasslands', + symmetry: syms[0].value, + armies: [ + { armyId: rules.armies[0].id, commanderId: rules.commanders[0]?.id, skill: 3 }, + { armyId: rules.armies[1].id, commanderId: rules.commanders[1]?.id, skill: 3 }, + ], + }; + + const left = 300, right = GAME_WIDTH - 700; + cycler(scene, objs, left, 300, 'MAP SIZE', sizes, 0, (v) => { cfg.size = v; }); + cycler(scene, objs, left, 400, 'THEME', themes, 0, (v) => { cfg.theme = v; }); + cycler(scene, objs, left, 500, 'SYMMETRY', syms, 0, (v) => { cfg.symmetry = v; }); + cycler(scene, objs, left, 600, 'OPPONENT SKILL', skills, 2, (v) => { cfg.armies[1].skill = v; }); + + const seedText = scene.add.text(left, 690, `SEED ${cfg.seed}`, { + fontFamily: FONT, fontSize: '22px', color: DIM, + }).setOrigin(0, 0.5).setDepth(902); + objs.push(seedText); + button(scene, objs, left + 420, 690, 150, 44, 'REROLL', () => { + cfg.seed = (Math.random() * 0xffffffff) >>> 0; + seedText.setText(`SEED ${cfg.seed}`); + }, { size: 20 }); + + // Commander picker — the armies double as the shared opponent personas. + const portraits = []; + const drawCommanders = () => { + for (const p of portraits) p.destroy?.(); + portraits.length = 0; + rules.commanders.forEach((c, i) => { + const col = i % 2, row = Math.floor(i / 2); + const x = right + col * 240, y = 320 + row * 210; + const chosen = cfg.armies[1].commanderId === c.id; + const box = scene.add.rectangle(x, y, 210, 190, chosen ? 0x2b3f5e : PANEL, 1) + .setStrokeStyle(2, chosen ? ACCENT : EDGE).setDepth(901) + .setInteractive({ useHandCursor: true }); + box.on('pointerdown', () => { + cfg.armies[1].commanderId = c.id; + cfg.armies[1].armyId = c.armyId; + // The player always takes the other side, so a CORE opponent means an ARM player. + const mine = rules.commanders.find((o) => o.armyId !== c.armyId) ?? rules.commanders[0]; + cfg.armies[0].commanderId = mine.id; + cfg.armies[0].armyId = mine.armyId; + drawCommanders(); + }); + const label = scene.add.text(x, y + 66, c.name, { + fontFamily: FONT, fontSize: '20px', color: chosen ? TEXT : DIM, + }).setOrigin(0.5).setDepth(903); + portraits.push(box, label); + objs.push(box, label); + try { + const opp = scene.oppById?.[c.opponentId]; + if (opp) { + const port = createOpponentPortrait(scene, opp, x, y - 24, 52, 902, { playIntro: false }); + for (const o of [].concat(port ?? [])) if (o) { portraits.push(o); objs.push(o); } + } + } catch (_) { /* portraits are decorative — a missing one must not block play */ } + }); + }; + objs.push(scene.add.text(right + 120, 250, 'ENEMY COMMANDER', { + fontFamily: FONT, fontSize: '22px', color: DIM, + }).setOrigin(0.5).setDepth(902)); + drawCommanders(); + + button(scene, objs, GAME_WIDTH / 2 - 180, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + button(scene, objs, GAME_WIDTH / 2 + 180, GAME_HEIGHT - 110, 300, 76, 'LAUNCH', () => onStart(cfg)); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Campaign list +// --------------------------------------------------------------------------- + +export function campaignList(scene, campaign, cleared, { onBack, onPick }) { + const objs = []; + const missions = campaign.missions ?? []; + backdrop(scene, objs, 'CAMPAIGN', + missions.length ? `${cleared} of ${missions.length} missions cleared` : 'No missions installed yet'); + + missions.forEach((m, i) => { + const col = i % 3, row = Math.floor(i / 3); + const x = GAME_WIDTH / 2 + (col - 1) * 500, y = 300 + row * 130; + // Missions unlock in order — the same rule the puzzle_progress endpoint enforces. + const unlocked = i <= cleared; + const b = button(scene, objs, x, y, 460, 104, + `${String(i + 1).padStart(2, '0')} ${unlocked ? m.name : 'LOCKED'}`, + () => onPick(i), { enabled: unlocked, size: 26 }); + if (unlocked && i < cleared) b.label.setColor('#9ce6a0'); + }); + + button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Pause +// --------------------------------------------------------------------------- + +export function pause(scene, { settings, onResume, onSave, onQuit, onToggleFog, onToggleEdge, onSpeed }) { + const objs = []; + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62) + .setOrigin(0, 0).setDepth(900).setInteractive()); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const panel = scene.add.rectangle(cx, cy, 620, 640, PANEL, 0.98) + .setStrokeStyle(3, EDGE).setDepth(901); + objs.push(panel); + objs.push(scene.add.text(cx, cy - 260, 'PAUSED', { + fontFamily: FONT, fontSize: '54px', color: TEXT, + }).setOrigin(0.5).setDepth(902)); + + const fogBtn = button(scene, objs, cx, cy - 150, 440, 62, + `FOG OF WAR: ${settings.fog !== false ? 'ON' : 'OFF'}`, () => { + const on = !(settings.fog !== false); + onToggleFog(on); + fogBtn.label.setText(`FOG OF WAR: ${on ? 'ON' : 'OFF'}`); + }, { size: 24 }); + + const edgeBtn = button(scene, objs, cx, cy - 76, 440, 62, + `EDGE SCROLL: ${settings.edgeScroll !== false ? 'ON' : 'OFF'}`, () => { + const on = !(settings.edgeScroll !== false); + onToggleEdge(on); + edgeBtn.label.setText(`EDGE SCROLL: ${on ? 'ON' : 'OFF'}`); + }, { size: 24 }); + + const speeds = [0.5, 1, 2]; + let si = 1; + const speedBtn = button(scene, objs, cx, cy - 2, 440, 62, 'SPEED: 1x', () => { + si = (si + 1) % speeds.length; + onSpeed(speeds[si]); + speedBtn.label.setText(`SPEED: ${speeds[si]}x`); + }, { size: 24 }); + + button(scene, objs, cx, cy + 88, 440, 66, 'RESUME', onResume); + button(scene, objs, cx, cy + 166, 440, 66, 'SAVE', onSave); + button(scene, objs, cx, cy + 244, 440, 66, 'QUIT TO MENU', onQuit); + return screen(scene, objs); +} + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +export function results(scene, { won, stats, elapsed }, { onMenu, onAgain }) { + const objs = []; + objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7) + .setOrigin(0, 0).setDepth(900).setInteractive()); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + objs.push(scene.add.rectangle(cx, cy, 760, 560, PANEL, 0.98).setStrokeStyle(3, EDGE).setDepth(901)); + objs.push(scene.add.text(cx, cy - 210, won ? 'VICTORY' : 'DEFEAT', { + fontFamily: FONT, fontSize: '72px', color: won ? '#9ce6a0' : '#ff8a6b', + }).setOrigin(0.5).setDepth(902)); + + const mins = Math.floor(elapsed / 60), secs = Math.floor(elapsed % 60); + const lines = [ + `Duration ${mins}m ${String(secs).padStart(2, '0')}s`, + `Units built ${stats.built}`, + `Units lost ${stats.lost}`, + `Kills ${stats.kills}`, + `Mass left ${stats.mass}`, + `Energy left ${stats.energy}`, + ]; + objs.push(scene.add.text(cx, cy - 100, lines.join('\n'), { + fontFamily: FONT, fontSize: '26px', color: TEXT, align: 'left', lineSpacing: 10, + }).setOrigin(0.5, 0).setDepth(902)); + + button(scene, objs, cx - 170, cy + 210, 300, 72, 'PLAY AGAIN', onAgain); + button(scene, objs, cx + 170, cy + 210, 300, 72, 'MENU', onMenu); + return screen(scene, objs); +} diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 9200b06..b49b532 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -61,9 +61,9 @@ export default class TAWorldView { this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection); this.gBars = scene.add.graphics().setDepth(DEPTHS.bars); this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost); - this.worldRoot.add(this.gSelection); - this.worldRoot.add(this.gBars); - this.worldRoot.add(this.gGhost); + this._addWorld(this.gSelection); + this._addWorld(this.gBars); + this._addWorld(this.gGhost); this.sprites = new Map(); // entity id -> { img, turret } this.selection = new Set(); @@ -90,6 +90,22 @@ export default class TAWorldView { cam.ignore(this.uiRoot); } + /** + * Add a child to the world container. + * + * Phaser Containers render their children in INSERTION order and never sort by depth on + * their own — `sortChildrenFlag` exists on DisplayList and Layer, not Container. So depth + * here is inert until `sort('depth')` runs, which is why the fog image (built in the + * constructor, long before the terrain chunks it must cover) was drawn underneath the map + * and the whole battlefield was visible. Anything added mid-frame — a lazily painted + * terrain chunk, a new unit sprite — re-dirties the order. + */ + _addWorld(obj) { + this.worldRoot.add(obj); + this._layerDirty = true; + return obj; + } + /** Keep screen-space extras (music controls, fullscreen button) off the world camera. */ ignoreOnWorldCam(objs) { if (!objs?.length) return; @@ -103,22 +119,50 @@ export default class TAWorldView { centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); } + /** + * Zoom one step, keeping whatever the cursor is over pinned under the cursor. + * + * The scroll is solved directly from the camera's screen->world mapping rather than by + * diffing two getWorldPoint() calls around the zoom change. getWorldPoint inverts + * `camera.matrix`, and that matrix is only rebuilt in preRender — so the second call reads + * the OLD zoom while the camera already holds the new one, and the correction it yields is + * wrong. Solving it makes the anchor exact and independent of when in the frame this runs. + * + * At the edges of the map the scroll clamps to the camera bounds, so the anchor gives way + * rather than letting the view slide off the world. + */ zoomBy(dir, focusX, focusY) { const cam = this.scene.cameras.main; const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir)); if (next === this.zoomIdx) return; - // Zoom toward the cursor: keep the world point under the pointer pinned. - const before = cam.getWorldPoint(focusX, focusY); + const anchor = this.worldPoint(focusX, focusY); this.zoomIdx = next; - cam.setZoom(ZOOMS[next]); - const after = cam.getWorldPoint(focusX, focusY); - cam.setScroll(cam.scrollX + (before.x - after.x), cam.scrollY + (before.y - after.y)); + const z = ZOOMS[next]; + cam.setZoom(z); + cam.setScroll( + anchor.x - cam.width / 2 + cam.width / (2 * z) - focusX / z, + anchor.y - cam.height / 2 + cam.height / (2 * z) - focusY / z, + ); } get zoom() { return this.scene.cameras.main.zoom; } + /** + * Screen -> world, from the camera's live scroll and zoom. + * + * Deliberately not Camera.getWorldPoint(): that inverts a matrix rebuilt only during + * preRender, so it reports a stale transform for any call made after the camera has moved + * earlier in the same frame — which is every click, since camera panning runs first in + * update(). This mirrors Camera.preRender's own maths: + * worldView.x = scrollX + width/2 - width/(2*zoom), worldX = worldView.x + screenX/zoom + */ worldPoint(screenX, screenY) { - return this.scene.cameras.main.getWorldPoint(screenX, screenY); + const cam = this.scene.cameras.main; + const z = cam.zoom; + return { + x: cam.scrollX + cam.width / 2 - cam.width / (2 * z) + screenX / z, + y: cam.scrollY + cam.height / 2 - cam.height / (2 * z) + screenY / z, + }; } // ------------------------------------------------------------------------- @@ -142,7 +186,7 @@ export default class TAWorldView { const w = Math.min(CHUNK_PX, state.worldW - ox); const h = Math.min(CHUNK_PX, state.worldH - oy); const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain); - this.worldRoot.add(rt); + this._addWorld(rt); const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts); const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts); @@ -219,7 +263,7 @@ export default class TAWorldView { // Linear filtering upscales the coarse grid into a soft gradient for the cost of a few // hundred pixels of texture — far cheaper than a per-tile fog RenderTexture. this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR - this.worldRoot.add(this.fogImg); + this._addWorld(this.fogImg); this.fogDirty = true; } @@ -277,14 +321,14 @@ export default class TAWorldView { } else { img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w); } - this.worldRoot.add(img); + this._addWorld(img); let turret = null; if (!def.isBuilding && def.turretFrame != null) { turret = this.scene.add.image(e.x, e.y, key, def.turretFrame); turret.setTint(sheets.color); turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w); - this.worldRoot.add(turret); + this._addWorld(turret); } s = { img, turret, defId: e.defId, site: e.site }; this.sprites.set(e.id, s); @@ -371,6 +415,11 @@ export default class TAWorldView { this._drawPlacementGhost(); + // Actor depth is recomputed every frame from Y, so the container has to be re-sorted + // every frame too — not just when a child is added. + this.worldRoot.sort('depth'); + this._layerDirty = false; + // Nanolathe links: builder -> whatever it is working on. const nanoLinks = []; for (const e of state.entities) { @@ -432,7 +481,7 @@ export default class TAWorldView { if (!on) return; const { state, rules, ts } = this; const g = this.scene.add.graphics().setDepth(DEPTHS.decal); - this.worldRoot.add(g); + this._addWorld(g); g.lineStyle(2, 0xffd27a, 0.85); for (let ty = 0; ty < state.h; ty++) { for (let tx = 0; tx < state.w; tx++) { diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js new file mode 100644 index 0000000..07cf16a --- /dev/null +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -0,0 +1,665 @@ +// Total Annihilation — the Phaser scene. +// +// A thin coordinator: it owns the fixed-step loop, input bindings and screen routing, and +// holds NO game rules. Every state change goes through TALogic.issueOrder, the same entry +// point the AI uses — which is what lets tools/verifyTotalAnnihilation.js bot the human side +// and assert a campaign mission is actually winnable. +// +// The loop is TALogic.step(state, rules, deltaMs): the accumulator lives inside the headless +// state (the StarControlLogic.stepMatch idiom), so the verify script drives an identical loop +// in Node with a synthetic delta and gets identical results. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { getGameSoundtrack } from '../../services/soundtrack.js'; +import { api } from '../../services/api.js'; +import { compileRules } from './TARules.js'; +import { generateMap, decodeMap } from './TAMapGen.js'; +import * as Logic from './TALogic.js'; +import { runAI } from './TAAI.js'; +import TAWorldView, { DEPTHS } from './TAWorldView.js'; +import TAFx from './TAFx.js'; +import TAHud from './TAHud.js'; +import * as Screens from './TAScreens.js'; + +const SAVE_KEY = 'totalannihilation-save'; +const SETTINGS_KEY = 'totalannihilation-settings'; +const EDGE_PAN = 26; // screen-edge scroll band, px +const PAN_SPEED = 1100; // px/s at zoom 1 +const DRAG_MIN = 8; // px before a click becomes a drag-box + +export default class TotalAnnihilationGame extends Phaser.Scene { + constructor() { super('TotalAnnihilationGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'totalannihilation', name: 'Total Annihilation' }; + this.phase = 'menu'; // menu | setup | briefing | playing | paused | result + this.match = null; + this.view = null; + this.hud = null; + this.fx = null; + this.screen = null; + this.selection = new Set(); + this.groups = new Map(); + this.placement = null; + this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack' + this.simSpeed = 1; + this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true }); + } + + create() { + try { + const { tracks, volume } = getGameSoundtrack(this); + if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); + } catch (_) { /* soundtrack is optional */ } + + this.input.mouse?.disableContextMenu(); + + this.rules = compileRules(this.cache.json.get('totalannihilation-rules')); + this.art = this.cache.json.get('totalannihilation-artwork'); + this.campaign = this.cache.json.get('totalannihilation-campaign') ?? { missions: [] }; + + this.keys = this.input.keyboard.addKeys( + 'W,A,S,D,X,H,P,G,Q,E,ESC,SPACE,UP,DOWN,LEFT,RIGHT,SHIFT,CTRL', + ); + this._bindInput(); + + // Commander portraits come from the shared opponent roster; a fetch failure just means + // the setup screen falls back to name-only cards. + this.oppById = {}; + fetch('data/opponents.json') + .then((r) => r.json()) + .then((d) => { for (const o of d.opponents ?? []) this.oppById[o.id] = o; }) + .catch(() => { /* portraits are optional */ }); + + this.events.once('shutdown', () => this._teardown()); + this.showMenu(); + } + + // ------------------------------------------------------------------------- + // Screen routing + // ------------------------------------------------------------------------- + + swapScreen(next) { + this.screen?.destroy(); + this.screen = next ?? null; + } + + showMenu() { + this.phase = 'menu'; + this._endMatch(); + this.swapScreen(Screens.mainMenu(this, { + hasSave: !!localStorage.getItem(SAVE_KEY), + onSkirmish: () => this.showSkirmishSetup(), + onCampaign: () => this.showCampaign(), + onContinue: () => this.loadSave(), + onLeave: () => this.scene.start('GameMenu'), + })); + } + + showSkirmishSetup() { + this.phase = 'setup'; + this.swapScreen(Screens.skirmishSetup(this, { + onBack: () => this.showMenu(), + onStart: (cfg) => this.startSkirmish(cfg), + })); + } + + showCampaign() { + this.phase = 'setup'; + api.get(`/puzzles/${this.gameDef.slug}/progress`) + .then((p) => p?.levelsCompleted ?? 0) + .catch(() => 0) + .then((cleared) => { + this.swapScreen(Screens.campaignList(this, this.campaign, cleared, { + onBack: () => this.showMenu(), + onPick: (idx) => this.startMission(idx), + })); + }); + } + + // ------------------------------------------------------------------------- + // Match lifecycle + // ------------------------------------------------------------------------- + + startSkirmish(cfg) { + const map = generateMap(this.rules, { + seed: cfg.seed, size: cfg.size, theme: cfg.theme, symmetry: cfg.symmetry, + armies: cfg.armies.length, + }); + const state = Logic.createMatch(this.rules, { + seed: cfg.seed, map, + armies: cfg.armies.map((a, i) => ({ + armyId: a.armyId, commanderId: a.commanderId, + isHuman: i === 0, aiSkill: a.skill, + aiProfile: this.rules.commanderById?.[a.commanderId]?.aiProfile ?? null, + })), + }); + this.meta = { mode: 'skirmish', cfg }; + this._beginMatch(state, 0); + } + + startMission(idx) { + const m = this.campaign.missions[idx]; + if (!m) return this.showMenu(); + const map = decodeMap(this.rules, m.map); + const state = Logic.createMatch(this.rules, { + seed: m.seed ?? 1234, map, + armies: [ + { armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true }, + ...(m.enemies ?? []).map((e) => ({ + armyId: e.army, commanderId: e.commander, + aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null, + })), + ], + }); + (m.startResources ?? []).forEach((r, i) => { + if (!state.armies[i]) return; + state.armies[i].mass = r.mass ?? state.armies[i].mass; + state.armies[i].energy = r.energy ?? state.armies[i].energy; + }); + this.meta = { mode: 'campaign', missionIdx: idx }; + this.mission = m; + this._beginMatch(state, 0); + } + + _beginMatch(state, playerArmy) { + this.swapScreen(null); + this.match = state; + this.playerArmy = playerArmy; + this.selection.clear(); + this.groups.clear(); + this.placement = null; + + this.view = new TAWorldView(this, this.rules, this.art, state, playerArmy); + this.view.setFogEnabled(this.settings.fog !== false); + this.fx = new TAFx(this, this.view.worldRoot, DEPTHS); + this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, { + onBuildPick: (def) => this._beginPlacement(def), + onProduce: (def, n) => this._enqueue(def, n), + onMinimapJump: (x, y) => this.view.centerOn(x, y), + }); + if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs); + + const start = state.starts.find((s) => s.army === playerArmy); + if (start) this.view.centerOn(start.x * state.tileSize, start.y * state.tileSize); + + this.phase = 'playing'; + this._lastAutosave = this.time.now; + } + + _endMatch() { + this.hud?.destroy(); this.hud = null; + this.fx?.destroy(); this.fx = null; + this.view?.destroy(); this.view = null; + this.match = null; + this.selection.clear(); + } + + _teardown() { + this._endMatch(); + this.screen?.destroy(); + this.music?.destroy?.(); + } + + // ------------------------------------------------------------------------- + // The loop + // ------------------------------------------------------------------------- + + update(time, delta) { + if (this.phase !== 'playing' || !this.match) return; + const st = this.match; + + this._updateCamera(delta); + + // The AI thinks on sim ticks, not render frames, so it is stepped inside the same + // fixed-step loop the simulation uses — otherwise its cadence would ride framerate. + const before = st.tick; + const events = Logic.step(st, this.rules, delta * this.simSpeed, 4, (s) => { + for (const a of s.armies) { + if (!a.alive || a.isHuman) continue; + runAI(this.rules, s, a.idx, { skill: a.aiSkill, ...(a.aiProfile ?? {}) }); + } + }); + if (st.tick !== before) this.view.fogDirty = true; + + for (const ev of events) this._onSimEvent(ev); + + const { nanoLinks, projectiles } = this.view.render(st.alpha); + this.fx.draw(delta, nanoLinks, projectiles, time); + this._syncSelection(); + this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null); + this._drawDragBox(); + + if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) { + this._lastAutosave = time; + this.saveGame(); + } + if (st.over) this._finish(st.over); + } + + _onSimEvent(ev) { + this.fx.onEvent(ev, this.rules); + if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) { + // Terrain under a finished structure changes, so its chunk has to be restamped. + this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128); + } + if (ev.t === 'armyEliminated' && ev.army !== this.playerArmy) { + this.hud.toast(`${this.rules.armies[ev.army]?.name ?? 'Enemy'} eliminated`, '#9ce6a0'); + } + if (ev.t === 'noPath' && ev.army === this.playerArmy) { + this.hud.toast('No route there', '#ff9a6b'); + } + } + + _finish(over) { + this.phase = 'result'; + const won = over.winner === this.playerArmy; + api.post('/history/single-player', { + slug: this.gameDef.slug, score: Math.round(this.match.elapsedSec), + opponentScores: [], result: won ? 'win' : 'loss', + }).catch(() => { /* best effort */ }); + + if (won && this.meta?.mode === 'campaign') { + api.post(`/puzzles/${this.gameDef.slug}/complete`, { level: this.meta.missionIdx + 1 }) + .catch(() => { /* best effort */ }); + } + localStorage.removeItem(SAVE_KEY); + + const stats = summarise(this.match, this.playerArmy); + this.swapScreen(Screens.results(this, { won, stats, elapsed: this.match.elapsedSec }, { + onMenu: () => this.showMenu(), + onAgain: () => { + if (this.meta?.mode === 'campaign') this.startMission(this.meta.missionIdx); + else this.startSkirmish({ ...this.meta.cfg, seed: (this.meta.cfg.seed * 7919 + 13) >>> 0 }); + }, + })); + } + + // ------------------------------------------------------------------------- + // Camera + // ------------------------------------------------------------------------- + + _updateCamera(delta) { + const k = this.keys; + const dt = delta / 1000; + let dx = 0, dy = 0; + if (k.A.isDown || k.LEFT.isDown) dx -= 1; + if (k.D.isDown || k.RIGHT.isDown) dx += 1; + if (k.W.isDown || k.UP.isDown) dy -= 1; + if (k.S.isDown || k.DOWN.isDown) dy += 1; + + // Edge scroll stays off until the mouse has actually moved over the canvas. Phaser's + // activePointer sits at (0,0) until its first event, which is inside the top-left edge + // band — so without this the camera silently flies off the corner of the map the moment + // a match starts, taking the player's Commander off screen before they touch anything. + if (this.settings.edgeScroll !== false && this._pointerLive && !this._dragging) { + const p = this.input.activePointer; + if (p.x >= 0 && p.y >= 0 && p.x <= GAME_WIDTH && p.y <= GAME_HEIGHT) { + if (p.x < EDGE_PAN) dx -= 1; + if (p.x > GAME_WIDTH - EDGE_PAN) dx += 1; + if (p.y < EDGE_PAN) dy -= 1; + if (p.y > GAME_HEIGHT - EDGE_PAN) dy += 1; + } + } + if (dx || dy) { + const len = Math.hypot(dx, dy) || 1; + this.view.panBy((dx / len) * PAN_SPEED * dt, (dy / len) * PAN_SPEED * dt); + } + } + + // ------------------------------------------------------------------------- + // Input + // ------------------------------------------------------------------------- + + _bindInput() { + this.input.on('pointermove', () => { this._pointerLive = true; }); + this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); }); + this.input.on('pointerup', (p) => this._onPointerUp(p)); + this.input.on('wheel', (p, objs, dx, dy) => { + if (this.phase !== 'playing') return; + this.view.zoomBy(dy > 0 ? -1 : 1, p.x, p.y); + }); + + this.input.keyboard.on('keydown', (ev) => { + if (this.phase === 'playing') this._onKey(ev); + }); + } + + _onKey(ev) { + const code = ev.key?.toLowerCase(); + if (ev.code === 'Escape') { + if (this.placement) { this._cancelPlacement(); return; } + if (this.pendingCommand) { this.pendingCommand = null; return; } + this.togglePause(); + return; + } + // Control groups: Ctrl+N assigns, N recalls, N again centres on the group. + if (/^[1-9]$/.test(code)) { + const n = Number(code); + if (ev.ctrlKey) { + this.groups.set(n, [...this.selection]); + this.hud.toast(`Group ${n} set (${this.selection.size})`); + } else { + const ids = (this.groups.get(n) ?? []).filter((id) => Logic.entityById(this.match, id)); + if (!ids.length) return; + const same = ids.length === this.selection.size && ids.every((id) => this.selection.has(id)); + this.selection = new Set(ids); + if (same) { + const e = Logic.entityById(this.match, ids[0]); + if (e) this.view.centerOn(e.x, e.y); + } + } + return; + } + switch (code) { + case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break; + case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break; + case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break; + case 'x': this._order({ type: 'stop' }); break; + case 'h': this._order({ type: 'hold' }); break; + case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break; + case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break; + case ' ': this._jumpToAction(); break; + default: break; + } + if (ev.ctrlKey && code === 'a') this._selectAll(); + } + + _onPointerDown(p) { + if (this.phase !== 'playing') return; + if (this.hud.hitsUi(p.x, p.y)) return; + + if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; } + + const w = this.view.worldPoint(p.x, p.y); + if (p.rightButtonDown()) { this._issueContextual(w, p.event?.shiftKey); return; } + + if (this.placement) { this._commitPlacement(w, p.event?.shiftKey); return; } + if (this.pendingCommand) { this._applyPendingCommand(w, p.event?.shiftKey); return; } + + this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey }; + this._dragging = false; + } + + _onPointerUp(p) { + this._panAnchor = null; + if (this.phase !== 'playing' || !this._dragStart) return; + const d = this._dragStart; + this._dragStart = null; + const w = this.view.worldPoint(p.x, p.y); + if (this._dragging) this._selectInBox(d.wx, d.wy, w.x, w.y, d.add); + else this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1); + this._dragging = false; + } + + _drawDragBox() { + if (!this._dragBoxG) this._dragBoxG = this.add.graphics().setDepth(2000); + const g = this._dragBoxG; + g.clear(); + if (!this._dragStart) return; + const p = this.input.activePointer; + if (Math.abs(p.x - this._dragStart.x) + Math.abs(p.y - this._dragStart.y) > DRAG_MIN) this._dragging = true; + if (!this._dragging) return; + this.view.uiRoot.add(g); + const x = Math.min(p.x, this._dragStart.x), y = Math.min(p.y, this._dragStart.y); + const w = Math.abs(p.x - this._dragStart.x), h = Math.abs(p.y - this._dragStart.y); + g.fillStyle(0x7dff9b, 0.10); g.fillRect(x, y, w, h); + g.lineStyle(1.5, 0x7dff9b, 0.9); g.strokeRect(x, y, w, h); + + if (this._panAnchor) { + this.view.panBy(this._panAnchor.x - p.x, this._panAnchor.y - p.y); + this._panAnchor = { x: p.x, y: p.y }; + } + } + + // ------------------------------------------------------------------------- + // Selection + // ------------------------------------------------------------------------- + + _mine() { + return this.match.entities.filter((e) => !e.dead && e.army === this.playerArmy); + } + + _selectedEntities() { + return [...this.selection].map((id) => Logic.entityById(this.match, id)).filter(Boolean); + } + + /** Drop dead entities so the HUD and orders never reference a corpse. */ + _syncSelection() { + for (const id of [...this.selection]) { + const e = Logic.entityById(this.match, id); + if (!e || e.dead) this.selection.delete(id); + } + this.view.selection = this.selection; + } + + _selectAt(x, y, add, double) { + let best = null, bestD = Infinity; + for (const e of this.match.entities) { + if (e.dead) continue; + if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; + const d = Math.hypot(e.x - x, e.y - y); + const r = e.isBuilding + ? Math.max(e.radius, Math.max(...Object.values(this.rules.defById[e.defId].footprint)) * this.match.tileSize * 0.5) + : e.radius + 6; + if (d <= r && d < bestD) { best = e; bestD = d; } + } + if (!best) { if (!add) this.selection.clear(); return; } + if (!add) this.selection.clear(); + if (double && best.army === this.playerArmy) { + // Double-click grabs every on-screen unit of the same type — the standard RTS idiom. + const cam = this.cameras.main.worldView; + for (const e of this._mine()) { + if (e.defId === best.defId && Phaser.Geom.Rectangle.Contains(cam, e.x, e.y)) this.selection.add(e.id); + } + return; + } + this.selection.add(best.id); + } + + _selectInBox(x0, y0, x1, y1, add) { + if (!add) this.selection.clear(); + const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }; + const hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) }; + // A box prefers mobile units: dragging over a base to grab tanks shouldn't also + // select the factory they came out of. + const hits = this._mine().filter((e) => e.x >= lo.x && e.x <= hi.x && e.y >= lo.y && e.y <= hi.y); + const mobile = hits.filter((e) => !e.isBuilding && !e.site); + for (const e of (mobile.length ? mobile : hits)) this.selection.add(e.id); + } + + _selectAll() { + this.selection.clear(); + for (const e of this._mine()) if (!e.isBuilding && !e.site) this.selection.add(e.id); + } + + _jumpToAction() { + const e = this._mine().find((u) => u.targetId) ?? this._mine()[0]; + if (e) this.view.centerOn(e.x, e.y); + } + + // ------------------------------------------------------------------------- + // Orders + // ------------------------------------------------------------------------- + + _order(order, queue = false) { + const unitIds = [...this.selection]; + if (!unitIds.length) return { ok: false }; + const r = Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, unitIds, order, queue, + }); + if (!r.ok && r.error) this.hud.toast(r.error, '#ff9a6b'); + return r; + } + + /** Right-click: move, attack an enemy, or set a factory's rally point. */ + _issueContextual(w, queue) { + const sel = this._selectedEntities(); + if (!sel.length) return; + + const factories = sel.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length); + if (factories.length === sel.length) { + for (const f of factories) { + Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y }, + }); + } + this.hud.toast('Rally point set'); + return; + } + + const target = this._entityAt(w.x, w.y); + if (target && target.army !== this.playerArmy && this.view.visibleToPlayer(target)) { + this._order({ type: 'attack', targetId: target.id }, queue); + return; + } + if (target && target.army === this.playerArmy && target.site) { + this._order({ type: 'assist', targetId: target.id }, queue); + return; + } + this._order({ type: 'move', x: w.x, y: w.y }, queue); + } + + _applyPendingCommand(w, queue) { + const cmd = this.pendingCommand; + this.pendingCommand = null; + if (cmd === 'guard') { + const t = this._entityAt(w.x, w.y); + if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue); + return; + } + this._order({ type: cmd, x: w.x, y: w.y }, queue); + } + + _entityAt(x, y) { + let best = null, bestD = Infinity; + for (const e of this.match.entities) { + if (e.dead) continue; + const d = Math.hypot(e.x - x, e.y - y); + if (d <= e.radius + 8 && d < bestD) { best = e; bestD = d; } + } + return best; + } + + _enqueue(def, count) { + const f = this._selectedEntities().find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.includes(def.id)); + if (!f) return; + Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, + order: { type: 'factoryEnqueue', factoryId: f.id, defId: def.id, count }, + }); + } + + // ------------------------------------------------------------------------- + // Building placement + // ------------------------------------------------------------------------- + + _beginPlacement(def) { + const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id)); + if (!builder) return; + this.placement = { def, builderId: builder.id }; + this.view.highlightMassSpots(!!def.terrainMultiplier); + this.input.on('pointermove', this._onPlacementMove, this); + this._onPlacementMove(this.input.activePointer); + } + + _onPlacementMove(p) { + if (!this.placement) return; + const w = this.view.worldPoint(p.x, p.y); + const ts = this.match.tileSize; + const def = this.placement.def; + // Centre the footprint on the cursor, then snap to the tile grid. + const tx = Math.round(w.x / ts - def.footprint.w / 2); + const ty = Math.round(w.y / ts - def.footprint.h / 2); + const builder = Logic.entityById(this.match, this.placement.builderId); + this.placement.tx = tx; this.placement.ty = ty; + this.view.placement = { + def, tx, ty, + legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def), + builderX: builder?.x, builderY: builder?.y, + buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0, + }; + } + + _commitPlacement(w, queue) { + const p = this.placement; + if (!p) return; + const r = Logic.issueOrder(this.match, this.rules, { + army: this.playerArmy, unitIds: [p.builderId], + order: { type: 'build', defId: p.def.id, tx: p.tx, ty: p.ty }, + queue, + }); + if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; } + // Shift keeps the cursor loaded so a row of generators is one gesture. + if (!queue) this._cancelPlacement(); + } + + _cancelPlacement() { + this.placement = null; + this.view.placement = null; + this.view.highlightMassSpots(false); + this.input.off('pointermove', this._onPlacementMove, this); + } + + // ------------------------------------------------------------------------- + // Pause / persistence + // ------------------------------------------------------------------------- + + togglePause() { + if (this.phase === 'playing') { + this.phase = 'paused'; + this.swapScreen(Screens.pause(this, { + settings: this.settings, + onResume: () => { this.phase = 'playing'; this.swapScreen(null); }, + onSave: () => { this.saveGame(); this.hud.toast('Saved'); }, + onQuit: () => this.showMenu(), + onToggleFog: (on) => { + this.settings.fog = on; + writeJson(SETTINGS_KEY, this.settings); + this.view.setFogEnabled(on); + }, + onToggleEdge: (on) => { this.settings.edgeScroll = on; writeJson(SETTINGS_KEY, this.settings); }, + onSpeed: (v) => { this.simSpeed = v; }, + })); + } else if (this.phase === 'paused') { + this.phase = 'playing'; + this.swapScreen(null); + } + } + + saveGame() { + if (!this.match) return; + try { + localStorage.setItem(SAVE_KEY, JSON.stringify({ + v: Logic.SAVE_VERSION, meta: this.meta, state: Logic.serialize(this.match), + })); + } catch (_) { /* quota — a failed autosave must not kill the match */ } + } + + loadSave() { + const raw = readJson(SAVE_KEY, null); + if (!raw || raw.v !== Logic.SAVE_VERSION) { this.hud?.toast('No compatible save'); return; } + const state = Logic.deserialize(this.rules, raw.state); + this.meta = raw.meta; + if (raw.meta?.mode === 'campaign') this.mission = this.campaign.missions[raw.meta.missionIdx]; + this._beginMatch(state, 0); + } +} + +function summarise(state, armyIdx) { + const a = state.armies[armyIdx]; + return { + built: a.builtEver, lost: a.lostEver, kills: a.killsEver, + mass: Math.round(a.mass), energy: Math.round(a.energy), + }; +} + +function readJson(key, fallback) { + try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; } +} + +function writeJson(key, v) { + try { localStorage.setItem(key, JSON.stringify(v)); } catch (_) { /* ignore */ } +} diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md new file mode 100644 index 0000000..c7a2aac --- /dev/null +++ b/src/games/totalannihilation/sprites.md @@ -0,0 +1,173 @@ +# Total Annihilation — art spec + +Every sheet is **optional**. `data/totalannihilation-artwork.json` ships with `path: null` on +all of them, and `TAArt.js` paints a procedural stand-in at runtime, so the game is fully +playable with zero PNG files. Drop a sheet in and it replaces the stand-in with no code change. + +To install a sheet: save the PNG under `assets/images/totalannihilation/`, then set that +sheet's `path` in `data/totalannihilation-artwork.json`. Nothing else needs editing — the +asset manifest resolves every entry in the `sheets` map automatically +(`src/data/assetManifest.js`), so **new sheets never need a manifest change either**. + +--- + +## The two rules that make the art cheap + +1. **Units are drawn once, facing right.** `0 rad` is Phaser's `+x` axis, and the renderer + calls `setRotation(heading)` — so a unit needs exactly **one** frame, not eight facings. + Draw it pointing **right**, dead centre in its cell. +2. **Units are drawn in neutral grey.** The renderer applies `setTint(armyColour)` per army. + Paint in greys with the detail carried by *value* (light/dark), not hue; any hue you paint + is multiplied by the team colour and will read as muddy. Keep the brightest highlight near + white so the tint stays vivid. + +Terrain is exempt from both: tiles are drawn in full colour and are never rotated or tinted. + +--- + +## Sheets + +| Sheet name (JSON key) | Texture key | Cell | Cols | Contents | +|---|---|---|---|---| +| `arm-units` | `ta-arm-units` | 64×64 | 8 | ARM mobile units | +| `core-units` | `ta-core-units` | 64×64 | 8 | CORE mobile units | +| `arm-structures` | `ta-arm-structures` | 128×128 | 6 | ARM buildings | +| `core-structures` | `ta-core-structures` | 128×128 | 6 | CORE buildings | +| `terrain-grasslands` | `ta-terrain-grasslands` | 64×64 | 9 | Grassland tiles | +| `terrain-snowfields` | `ta-terrain-snowfields` | 64×64 | 9 | Snow tiles | +| `terrain-tropics` | `ta-terrain-tropics` | 64×64 | 9 | Jungle tiles | +| `icons` | `ta-icons` | 44×44 | 10 | Command/build glyphs | + +Frames are numbered **left to right, then top to bottom**, starting at 0. + +--- + +## Unit sheets (64×64 cells, 8 per row) + +Both army sheets use the identical frame layout — that is what lets one unit definition serve +every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and +let the scale do the work. + +| Frame | Unit | Drawn as | `spritePx` | +|---|---|---|---| +| 0 | Commander | hull / legs, no weapon | 56 | +| 1 | Commander turret | upper body + D-Gun arm | 56 | +| 2 | Infantry | whole figure, top-down | 30 | +| 3 | Sniper | whole figure, long rifle | 30 | +| 4 | Jeep | chassis + wheels | 40 | +| 5 | Jeep turret | pintle gun | 40 | +| 6 | Tank | hull + tracks | 54 | +| 7 | Tank turret | turret + barrel | 54 | +| 8 | Rocket Tank | hull + tracks | 56 | +| 9 | Rocket Tank turret | rocket pod | 56 | + +**Turret frames** are drawn as a separate image stacked on the hull and rotated +independently, so the unit aims while it drives. Rules: + +- The turret's **pivot is the centre of the cell** — draw the turret so its rotation point + sits at (32, 32), with the barrel extending to the **right**. +- Leave the rest of the turret cell transparent. The hull shows through it. +- A unit with no turret (infantry, sniper) simply has no turret frame; its whole sprite + rotates to face its target. + +Frames 10–63 are free. Add a unit by appending a definition to `units[]` in +`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code. + +--- + +## Structure sheets (128×128 cells, 6 per row) + +Buildings do **not** rotate. They are scaled to `footprint × 64px`, so a 2×2 building renders +at 128px and a 3×3 at 192px — draw the 3×3 buildings to fill the cell and accept that they are +scaled up 1.5×, or supply a larger cell size in the artwork JSON. + +Each building has **two** frames: the finished structure, and a **build frame** shown while it +is under construction (the renderer also fades it in by build progress). Draw the build frame +as a skeleton/scaffold version — girders, no panels, no lights. + +| Frame | Building | Footprint | Notes | +|---|---|---|---| +| 0 | Energy Generator | 2×2 | radiating fins / vents | +| 1 | Energy Generator — under construction | 2×2 | | +| 2 | Mass Generator | 2×2 | drill ring; sits on metal patches | +| 3 | Mass Generator — under construction | 2×2 | | +| 4 | Barracks | 3×3 | doorway on the **bottom** edge (units exit downward) | +| 5 | Barracks — under construction | 3×3 | | +| 6 | Vehicle Plant | 3×3 | wide bay door on the **bottom** edge | +| 7 | Vehicle Plant — under construction | 3×3 | | + +Frames 8–35 are free. + +--- + +## Terrain sheets (64×64 cells, 9 per row) + +**Every `kind: "terrain"` sheet must follow this exact frame order** — it is fixed in +`terrainFrames` in the artwork JSON, which is what lets a new theme be a pure drop-in: + +| Frame | Tile | Notes | +|---|---|---| +| 0 | ground | the base tile — most of the map | +| 1 | groundAlt1 | variation, scattered to break up tiling | +| 2 | groundAlt2 | second variation | +| 3 | rough | slows wheeled units; still buildable | +| 4 | cliff | impassable, not buildable | +| 5 | water | impassable to current units | +| 6 | waterDeep | impassable; used for the interior of large bodies | +| 7 | metal | metal patch — **doubles a Mass Generator's yield** | +| 8 | road | fast movement | + +Tiles must be **seamlessly tileable on all four edges**, since the terrain is stamped into +chunked RenderTextures with no blending. Keep them low-contrast: units and health bars are +drawn on top, and busy terrain makes a battle unreadable. + +Metal (frame 7) should read as *obviously* valuable at a glance — it is the one tile the +player hunts for. + +### Adding a new theme + +1. Draw a 9-frame terrain sheet in the order above. +2. Add it to `sheets` in `data/totalannihilation-artwork.json` with `"kind": "terrain"`. +3. Add an entry to `themes` naming that sheet, plus a `palette` giving a colour per terrain + id (`ground`, `rough`, `cliff`, `water`, `metal`, `road`, …). The palette drives the + minimap and the procedural fallback, so it is required even when a real sheet exists. + +The theme then appears in the skirmish setup screen automatically. + +--- + +## Icons (44×44 cells, 10 per row) + +Command glyphs for the HUD: move, attack, attack-move, stop, hold, patrol, guard, build, +repair, rally. Purely decorative today — the build menu labels itself from unit names — so +this sheet is the lowest priority of the eight. + +--- + +## Adding a third army (no code) + +Append two sheets (`kind: "unit"` and `kind: "structure"`) to the artwork JSON, then add a row +to `armies[]` in the rules JSON naming them plus a colour: + +```json +{ "id": "hive", "name": "HIVE", "color": "#8ad46a", + "unitSheet": "hive-units", "structureSheet": "hive-structures" } +``` + +Every existing unit and building definition works for the new army unchanged, because each +definition references a **sheet slot** (`unitSheet` / `structureSheet`) rather than a sheet +name. Add commanders for it in `commanders[]`, pointing `opponentId` at an entry in +`data/opponents.json` for the portrait. + +--- + +## Checking your work + +``` +node tools/verifyTotalAnnihilation.js --quick +``` + +Section 2 asserts that every declared sheet has a key and frame size, that each army's sheets +exist, that every theme has a palette entry for every terrain type, and that no unit or +building references a frame index beyond its sheet's `cols × rows` capacity — so a +mis-numbered frame fails the build instead of rendering the wrong tank in a battle. diff --git a/src/main.js b/src/main.js index d4f08e2..138a143 100644 --- a/src/main.js +++ b/src/main.js @@ -97,6 +97,7 @@ import SuperKartGame from './games/superkart/SuperKartGame.js'; import SuperKartEditor from './games/superkart/SuperKartEditor.js'; import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js'; import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js'; +import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js'; const config = { type: Phaser.AUTO, @@ -207,6 +208,7 @@ const config = { SuperKartEditor, AdvanceWarsGame, TetrisAttackGame, + TotalAnnihilationGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index fdf171f..d42db29 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index bf65c8e..043f131 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -49,6 +49,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('adventure-music', 'data/adventure-music.json'); this.load.json('advancewars-artwork', 'data/advancewars-artwork.json'); this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json'); + this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json'); this.load.json('rushhour', 'data/rushhour.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json'); this.load.json('shift-artwork', 'data/shift-artwork.json'); diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 7f41226..4644772 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { superkart: 'nintendo', advancewars: 'nintendo', tetrisattack: 'nintendo', + totalannihilation: 'hacker', coloradodefense: 'arcadedark', tempest: 'arcadedark', mastermind: 'hacker', diff --git a/tools/genTACampaign.js b/tools/genTACampaign.js new file mode 100644 index 0000000..74ce870 --- /dev/null +++ b/tools/genTACampaign.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Emits data/totalannihilation-campaign.json. +// +// Mission terrain is baked from the seeded generator at authoring time and frozen into the +// file as plain row strings — after that it is ordinary hand-editable map data, and the +// briefings, prebuilt bases, enemy skills and objectives below are hand-authored. Re-running +// this regenerates the terrain for the recorded seeds; editing the JSON by hand afterwards is +// entirely fine and will simply be overwritten if you run it again. +// +// node tools/genTACampaign.js + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { compileRules } from '../src/games/totalannihilation/TARules.js'; +import { generateMap } from '../src/games/totalannihilation/TAMapGen.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const rules = compileRules(JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'))); +const CH = rules.terrain.map((t) => t.ch); + +// Hand-authored mission table. `seed`/`size`/`symmetry` pick the battlefield; everything +// else is the mission design proper. +// +// The seeds are not arbitrary. Even a mirrored map can hand one start a decisive positional +// edge (metal spread, chokes, how much room the base has to grow), and across candidate seeds +// the same mission swung between unwinnable and a walkover. Every seed here was chosen by +// running a skill-5 bot on the player's side — the same assertion tools/ +// verifyTotalAnnihilation.js §13 makes, so a bad seed fails the build rather than shipping. +const MISSIONS = [ + { + id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', + enemy: { commander: 'klaxon', skill: 1, aggression: 0.25 }, + startResources: [{ mass: 1600, energy: 2600 }, { mass: 600, energy: 900 }], + prebuilt: [], + briefing: 'Establish a base and remove the CORE presence. Power first — nothing moves without it.', + victory: 'Beachhead secured. The corridor inland is open.', + defeat: 'The landing has been thrown back into the sea.', + }, + { + id: 'm02', name: 'Metal Fields', seed: 20773, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', + enemy: { commander: 'klaxon', skill: 2, aggression: 0.4 }, + startResources: [{ mass: 1300, energy: 2200 }, { mass: 900, energy: 1400 }], + prebuilt: [{ enemy: true, id: 'energygen', dx: 3, dy: -2 }], + briefing: 'Metal patches double a generator\'s yield. Take them before CORE does.', + victory: 'The patches are ours. Production doubles from here.', + defeat: 'CORE holds the metal. We cannot match their output.', + }, + { + id: 'm03', name: 'Cold Open', seed: 31287, size: 'small', theme: 'snowfields', symmetry: 'mirror-y', + enemy: { commander: 'cybro', skill: 2, aggression: 0.6 }, + startResources: [{ mass: 1200, energy: 2000 }, { mass: 1100, energy: 1800 }], + prebuilt: [{ id: 'energygen', dx: 3, dy: -2 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 }], + briefing: 'Cy-Bro opens with infantry. Armour answers infantry — get a Vehicle Plant up early.', + victory: 'Cy-Bro\'s foothold is slag.', + defeat: 'Overrun before the plant finished.', + }, + { + id: 'm04', name: 'The Long Ridge', seed: 44377, size: 'medium', theme: 'grasslands', symmetry: 'rotational', + enemy: { commander: 'klaxon', skill: 3, aggression: 0.5 }, + startResources: [{ mass: 2200, energy: 3600 }, { mass: 1300, energy: 2200 }], + prebuilt: [{ id: 'energygen', dx: 3, dy: -2 }, { id: 'energygen', dx: -3, dy: -2 }], + briefing: 'A fair fight on a wide map. Expand hard — whoever out-earns the other wins the third engagement.', + victory: 'The ridge is held.', + defeat: 'KLAXON out-produced us and it showed.', + }, + { + id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x', + enemy: { commander: 'cybro', skill: 4, aggression: 0.7 }, + startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }], + prebuilt: [ + { id: 'energygen', dx: 3, dy: -2 }, { id: 'energygen', dx: -3, dy: -2 }, + { id: 'barracks', dx: 4, dy: 3 }, + { enemy: true, id: 'energygen', dx: 3, dy: 2 }, + ], + briefing: 'Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields.', + victory: 'Cy-Bro is scrap on the sand.', + defeat: 'The push came faster than the plant did.', + }, + { + id: 'm06', name: 'Annihilation', seed: 63541, size: 'medium', theme: 'snowfields', symmetry: 'rotational', + enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 }, + startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }], + prebuilt: [ + { id: 'energygen', dx: 3, dy: -3 }, { id: 'energygen', dx: -3, dy: -3 }, + { id: 'barracks', dx: 4, dy: 3 }, { id: 'vehicleplant', dx: -4, dy: 3 }, + { enemy: true, id: 'energygen', dx: 3, dy: -3 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 }, + ], + briefing: 'KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it.', + victory: 'Total annihilation. There is nothing left to fight.', + defeat: 'KLAXON was simply better prepared.', + }, +]; + +function rowsFor(map) { + const rows = []; + for (let y = 0; y < map.h; y++) { + let s = ''; + for (let x = 0; x < map.w; x++) s += CH[map.terrain[y * map.w + x]]; + rows.push(s); + } + return rows; +} + +const missions = MISSIONS.map((m) => { + const map = generateMap(rules, { seed: m.seed, size: m.size, theme: m.theme, symmetry: m.symmetry, armies: 2 }); + const starts = map.starts.map((s) => ({ army: s.army, x: s.x, y: s.y })); + const enemyStart = starts.find((s) => s.army === 1) ?? starts[0]; + const playerCmd = rules.commanders.find((c) => c.armyId === 'arm'); + const enemyCmd = rules.commanderById[m.enemy.commander]; + + const playerStart = starts.find((s) => s.army === 0) ?? starts[0]; + const buildings = (m.prebuilt ?? []).map((b) => { + const anchor = b.enemy ? enemyStart : playerStart; + return { + army: b.enemy ? 1 : 0, type: b.id, + tx: Math.max(1, Math.min(map.w - 4, anchor.x + b.dx)), + ty: Math.max(1, Math.min(map.h - 4, anchor.y + b.dy)), + }; + }); + + return { + id: m.id, name: m.name, theme: m.theme, seed: m.seed, + playerArmy: 'arm', playerCommander: playerCmd.id, + enemies: [{ + army: enemyCmd.armyId, commander: enemyCmd.id, + aiProfile: { skill: m.enemy.skill, aggression: m.enemy.aggression }, + }], + startResources: m.startResources, + objective: { type: 'destroyAll' }, + briefing: [{ speaker: playerCmd.opponentId, mood: 'idle', text: m.briefing }], + victoryLine: { speaker: playerCmd.opponentId, mood: 'happy', text: m.victory }, + defeatLine: { speaker: enemyCmd.opponentId, mood: 'smug', text: m.defeat }, + map: { w: map.w, h: map.h, theme: m.theme, rows: rowsFor(map), starts, buildings }, + }; +}); + +const out = { _comment: 'Generated by tools/genTACampaign.js — terrain is baked, design is hand-authored.', missions }; +writeFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), `${JSON.stringify(out, null, 1)}\n`); +console.log(`wrote ${missions.length} missions`); +for (const m of missions) console.log(` ${m.id} ${m.name.padEnd(16)} ${m.map.w}x${m.map.h} skill ${m.enemies[0].aiProfile.skill}`); diff --git a/tools/lib/taStubScene.js b/tools/lib/taStubScene.js new file mode 100644 index 0000000..d4e75d9 --- /dev/null +++ b/tools/lib/taStubScene.js @@ -0,0 +1,134 @@ +// Minimal Phaser stand-in for tools/verifyTotalAnnihilation.js. +// +// TAWorldView, TAFx and TAArt deliberately import no Phaser, so the real render path can be +// exercised in Node against this. It models the behaviour that matters and NOT the behaviour +// that would hide bugs — in particular a Container here draws in insertion order and only +// reorders when sort() is called, exactly like the real thing. +export function makeStubScene(w = 1920, h = 1080) { + const created = []; + const track = (o) => { created.push(o); return o; }; + const base = (type, extra = {}) => { + const o = { + type, x: 0, y: 0, depth: 0, visible: true, alpha: 1, rotation: 0, + scaleX: 1, scaleY: 1, displayWidth: 0, displayHeight: 0, tint: null, + frame: null, texture: null, parent: null, + setPosition(x, y) { this.x = x; this.y = y; return this; }, + setOrigin() { return this; }, + setDepth(d) { this.depth = d; return this; }, + setVisible(v) { this.visible = v; return this; }, + setAlpha(a) { this.alpha = a; return this; }, + setRotation(r) { this.rotation = r; return this; }, + setScale(s) { this.scaleX = this.scaleY = s; return this; }, + setDisplaySize(dw, dh) { this.displayWidth = dw; this.displayHeight = dh; return this; }, + setTint(t) { this.tint = t; return this; }, + setTexture(k) { this.texture = k; return this; }, + setFrame(f) { this.frame = f; return this; }, + setInteractive() { return this; }, + setSize() { return this; }, + setStrokeStyle() { return this; }, + setFillStyle() { return this; }, + on() { return this; }, + destroy() { this.destroyed = true; }, + ...extra, + }; + return track(o); + }; + const gfx = () => base('graphics', { + ops: 0, + clear() { this.ops = 0; return this; }, + lineStyle() { return this; }, fillStyle() { return this; }, + beginPath() { return this; }, closePath() { return this; }, + moveTo() { return this; }, lineTo() { return this; }, + fillPath() { return this; }, strokePath() { return this; }, + fillRect() { this.ops++; return this; }, strokeRect() { this.ops++; return this; }, + fillCircle() { this.ops++; return this; }, strokeCircle() { this.ops++; return this; }, + fillEllipse() { this.ops++; return this; }, strokeEllipse() { this.ops++; return this; }, + lineBetween() { this.ops++; return this; }, fillTriangle() { this.ops++; return this; }, + fillRoundedRect() { this.ops++; return this; }, strokeRoundedRect() { this.ops++; return this; }, + }); + const container = () => base('container', { + list: [], + add(o) { for (const c of [].concat(o)) { if (c) { c.parent = this; this.list.push(c); } } return this; }, + remove() { return this; }, + // Phaser Containers draw in INSERTION order and only reorder when sort() is called, + // so the stub must not quietly sort for us or it hides exactly the bug we're hunting. + sort(prop) { this.list = this.list.slice().sort((a, b) => (a[prop] ?? 0) - (b[prop] ?? 0)); return this; }, + }); + const ctxStub = new Proxy({}, { + get: (_t, p) => { + if (p === 'createImageData') return (w2, h2) => ({ data: new Uint8ClampedArray(w2 * h2 * 4) }); + if (p === 'getImageData') return (x, y, w2, h2) => ({ data: new Uint8ClampedArray(w2 * h2 * 4) }); + return () => {}; + }, + set: () => true, + }); + const textures = new Map(); + // Camera maths copied from Phaser's Camera.preRender so the stub agrees with the engine: + // midX = scrollX + width/2; worldView.x = midX - (width/zoom)/2 + // Anything looser would let a broken zoom anchor pass. + const cam = (x = 0, y = 0, cw = w, ch = h) => ({ + x, y, width: cw, height: ch, + scrollX: 0, scrollY: 0, zoom: 1, ignored: [], + worldView: { x: 0, y: 0, width: cw, height: ch, right: cw, bottom: ch }, + setBounds() { return this; }, setBackgroundColor() { return this; }, + setZoom(z) { this.zoom = z; this._sync(); return this; }, + setScroll(sx, sy) { this.scrollX = sx; this.scrollY = sy; this._sync(); return this; }, + centerOn(px, py) { this.scrollX = px - cw / 2; this.scrollY = py - ch / 2; this._sync(); return this; }, + getWorldPoint(px, py) { + return { x: this.worldView.x + px / this.zoom, y: this.worldView.y + py / this.zoom }; + }, + ignore(o) { this.ignored.push(o); return this; }, + shake() { return this; }, + _sync() { + this.worldView.width = cw / this.zoom; + this.worldView.height = ch / this.zoom; + this.worldView.x = this.scrollX + cw / 2 - this.worldView.width / 2; + this.worldView.y = this.scrollY + ch / 2 - this.worldView.height / 2; + this.worldView.right = this.worldView.x + this.worldView.width; + this.worldView.bottom = this.worldView.y + this.worldView.height; + }, + }); + const main = cam(); + const scene = { + created, + scale: { width: w, height: h }, + time: { now: 1000, delayedCall: () => ({ remove() {} }) }, + tweens: { add: () => ({}) }, + input: { activePointer: { x: 0, y: 0 }, on() {}, off() {}, keyboard: { addKeys: () => ({}), on() {} }, mouse: { disableContextMenu() {} } }, + events: { once() {}, on() {} }, + cameras: { main, cameras: [main], add: () => cam(), remove() {} }, + add: { + container: () => container(), + graphics: () => gfx(), + image: (x, y, key, frame) => base('image', { x, y, texture: key, frame }), + rectangle: (x, y, rw, rh) => base('rectangle', { x, y, width: rw, height: rh }), + text: (x, y, t) => base('text', { x, y, text: t, width: 10, height: 10, setText(v) { this.text = v; return this; }, setColor() { return this; } }), + renderTexture: (x, y, rw, rh) => base('renderTexture', { + x, y, width: rw, height: rh, draws: 0, + beginDraw() { return this; }, batchDraw() { this.draws++; return this; }, endDraw() { return this; }, + draw() { this.draws++; return this; }, + }), + circle: (x, y) => base('circle', { x, y }), + }, + make: { image: (cfg) => base('image', { texture: cfg.key }), graphics: () => gfx() }, + textures: { + exists: (k) => textures.has(k), + remove: (k) => textures.delete(k), + get: (k) => textures.get(k), + createCanvas(key, cw2, ch2) { + if (!Number.isFinite(cw2) || !Number.isFinite(ch2) || cw2 <= 0 || ch2 <= 0) { + throw new Error(`createCanvas(${key}) got ${cw2}x${ch2}`); + } + const frames = []; + const t = { + key, width: cw2, height: ch2, frames, context: ctxStub, + getContext: () => ctxStub, refresh() {}, setFilter() { return this; }, + add(f) { frames.push(f); return this; }, + }; + textures.set(key, t); + return t; + }, + }, + }; + return scene; +} diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js new file mode 100644 index 0000000..4056e89 --- /dev/null +++ b/tools/verifyTotalAnnihilation.js @@ -0,0 +1,856 @@ +#!/usr/bin/env node +// Verifies the Total Annihilation engine, data and AI end to end. +// +// node tools/verifyTotalAnnihilation.js [--quick] +// +// Everything here runs headless: TARules/TALogic/TANav/TAMapGen/TAAI import no Phaser. The +// campaign section is the payoff of routing every order through TALogic.issueOrder — the AI +// plays the HUMAN side, so each mission gets an automated winnability assertion rather than a +// promise that it is probably beatable. + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { compileRules, armorMul } from '../src/games/totalannihilation/TARules.js'; +import { generateMap, decodeMap } from '../src/games/totalannihilation/TAMapGen.js'; +import * as L from '../src/games/totalannihilation/TALogic.js'; +import { runAI } from '../src/games/totalannihilation/TAAI.js'; +import { ensureSheets } from '../src/games/totalannihilation/TAArt.js'; +import TAWorldView, { DEPTHS } from '../src/games/totalannihilation/TAWorldView.js'; +import TAFx from '../src/games/totalannihilation/TAFx.js'; +import { makeStubScene } from './lib/taStubScene.js'; +import { + createNav, findPath, clearanceFor, segmentClear, tileOk, worldToTileX, worldToTileY, +} from '../src/games/totalannihilation/TANav.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const QUICK = process.argv.includes('--quick'); +const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); +const artJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-artwork.json'), 'utf8')); +const campaign = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), 'utf8')); +const opponents = JSON.parse(readFileSync(join(ROOT, 'data/opponents.json'), 'utf8')); +const rules = compileRules(rulesJson); +const HZ = rules.constants.tickHz; + +let pass = 0; const failures = []; +function check(name, cond, detail) { + if (cond) { pass++; return true; } + failures.push(detail ? `${name} — ${detail}` : name); + return false; +} +function section(title) { console.log(`\n── ${title}`); } + +// --------------------------------------------------------------------------- +section('1. Rules integrity'); +// --------------------------------------------------------------------------- +{ + const ids = new Set(); + for (const d of [...rules.units, ...rules.buildings]) { + check(`unique def id ${d.id}`, !ids.has(d.id), 'duplicate'); + ids.add(d.id); + } + for (const d of [...rules.units, ...rules.buildings]) { + check(`${d.id} costs are positive`, (d.cost?.mass ?? 0) >= 0 && (d.cost?.energy ?? 0) >= 0); + check(`${d.id} has hp`, d.hp > 0); + for (const w of d.weaponDefs ?? []) { + for (const a of rules.armorClasses) { + check(`${w.id} vs ${a}`, Number.isFinite(armorMul(w, a)), 'missing armorMul'); + } + } + } + for (const u of rules.units) { + if (!u.buildTime) continue; + const makers = rules.buildings.filter((b) => (b.builds ?? []).includes(u.id)); + check(`${u.id} is buildable by some factory`, makers.length > 0); + } + // Every producible thing must be reachable from the Commander's tech tree, or the player + // can see it in the rules and never build it. + const commander = rules.unitById.commander; + const reach = new Set(commander.builds ?? []); + for (let i = 0; i < 4; i++) { + for (const id of [...reach]) for (const n of rules.defById[id].builds ?? []) reach.add(n); + } + for (const d of [...rules.units, ...rules.buildings]) { + if (d.id === 'commander') continue; + check(`${d.id} reachable from the Commander`, reach.has(d.id)); + } + for (const c of rules.commanders) { + check(`commander ${c.id} maps to a real opponent`, + (opponents.opponents ?? []).some((o) => o.id === c.opponentId), c.opponentId); + check(`commander ${c.id} army exists`, !!rules.armyById[c.armyId]); + } + // The D-Gun must stay manual — auto-firing it turns every Commander into a base turret + // that deletes one attacker per reload, which makes assaulting anything suicide. + check('D-Gun is manual-fire', rules.weaponById.dgun.manual === true); + check('Commander has a non-manual weapon', + (commander.weaponDefs ?? []).some((w) => !w.manual), 'would be defenceless'); +} + +// --------------------------------------------------------------------------- +section('2. Artwork manifest'); +// --------------------------------------------------------------------------- +{ + const sheets = artJson.sheets ?? {}; + check('artwork declares sheets', Object.keys(sheets).length > 0); + for (const [name, s] of Object.entries(sheets)) { + check(`sheet ${name} has a key`, !!s.key); + check(`sheet ${name} has frame size`, s.frameWidth > 0 && s.frameHeight > 0); + check(`sheet ${name} has a kind`, ['unit', 'structure', 'terrain', 'icon'].includes(s.kind), s.kind); + } + for (const a of rules.armies) { + check(`army ${a.id} unit sheet declared`, !!sheets[a.unitSheet], a.unitSheet); + check(`army ${a.id} structure sheet declared`, !!sheets[a.structureSheet], a.structureSheet); + } + for (const [id, t] of Object.entries(artJson.themes ?? {})) { + check(`theme ${id} sheet declared`, !!sheets[t.sheet], t.sheet); + for (const terr of rules.terrain) { + check(`theme ${id} has a colour for ${terr.id}`, !!t.palette?.[terr.id]); + } + } + // terrainFrames drives the procedural terrain sheet's size. It also carries a `_comment` + // key, and one non-numeric value in there turns the frame count into NaN and the painted + // sheet into a zero-height canvas — which fails in the browser as an opaque WebGL error, + // so it gets caught here instead. + const tf = Object.entries(artJson.terrainFrames ?? {}).filter(([k]) => !k.startsWith('_')); + check('terrainFrames declares frames', tf.length > 0); + for (const [name, v] of tf) { + check(`terrainFrames.${name} is a frame index`, Number.isInteger(v) && v >= 0, String(v)); + } + for (const terr of rules.terrain) { + check(`terrain "${terr.id}" has a frame in terrainFrames`, + tf.some(([name]) => name === terr.id) || Number.isInteger(terr.frame)); + } + const maxTerrainFrame = Math.max(...tf.map(([, v]) => v)) + 1; + for (const [name, sheet] of Object.entries(sheets)) { + if (sheet.kind !== 'terrain') continue; + const cols = sheet.cols ?? 8; + check(`${name} layout is fully numeric`, + [sheet.frameWidth, sheet.frameHeight, cols].every((v) => Number.isFinite(v) && v > 0)); + check(`${name} holds every terrain frame`, maxTerrainFrame <= cols * Math.ceil(maxTerrainFrame / cols)); + } + + // Frame indices must exist within each sheet's declared capacity. + for (const d of [...rules.units, ...rules.buildings]) { + for (const a of rules.armies) { + const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet]; + const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); + check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap); + if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap); + } + } +} + +// --------------------------------------------------------------------------- +section('2b. Procedural art actually paints'); +// --------------------------------------------------------------------------- +{ + // TAArt imports no Phaser, so the painters can be run right here against a stub canvas. + // Checking the artwork JSON alone is not enough: the terrain sheet's frame count is DERIVED + // from that JSON, and a bad derivation produced a zero-height texture that only failed in + // the browser, as a WebGL error pointing nowhere near the cause. Running the painters also + // catches NaN geometry, which paints nothing at all and is otherwise invisible until a + // player sees a blank tank. + const bad = []; + const num = (where, ...vals) => { + for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where); + }; + const mkCtx = () => new Proxy({}, { + get: (_t, prop) => { + if (prop === 'save' || prop === 'restore' || prop === 'beginPath' || prop === 'closePath' + || prop === 'fill' || prop === 'stroke') return () => {}; + if (typeof prop === 'string') return (...args) => num(prop, ...args); + return () => {}; + }, + set: (_t, prop, value) => { num(String(prop), value); return true; }, + }); + const made = new Map(); + const scene = { + textures: { + exists: (k) => made.has(k), + remove: (k) => made.delete(k), + createCanvas(key, w, h) { + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + bad.push(`createCanvas(${key}, ${w}, ${h})`); + } + const frames = []; + const tex = { + width: w, height: h, frames, + getContext: () => mkCtx(), + refresh() {}, + add(f, _s, fx, fy, fw, fh) { + num(`add(${key})`, fx, fy, fw, fh); + if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`); + frames.push(f); + }, + }; + made.set(key, tex); + return tex; + }, + }, + }; + + const { keys, procedural } = ensureSheets(scene, rules, artJson); + for (const name of Object.keys(artJson.sheets ?? {})) { + check(`sheet ${name} resolves to a texture key`, !!keys[name]); + const tex = made.get(keys[name]); + check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0, + tex ? `${tex.width}x${tex.height}` : 'no texture'); + check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0); + } + check('every sheet fell back to a painted stand-in', procedural.length === Object.keys(artJson.sheets ?? {}).length, + `${procedural.length} procedural`); + check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', ')); + + // Every frame a def references must have been registered by the painter that owns it. + for (const d of [...rules.units, ...rules.buildings]) { + for (const a of rules.armies) { + const sheetName = d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet; + const tex = made.get(keys[sheetName]); + check(`${d.id} frame ${d.frame} was painted on ${sheetName}`, tex?.frames.includes(d.frame)); + if (d.turretFrame != null) { + check(`${d.id} turret frame was painted`, tex?.frames.includes(d.turretFrame)); + } + if (d.buildFrame != null) { + check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame)); + } + } + } + for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) { + if (sheet.kind !== 'terrain') continue; + const tex = made.get(keys[name]); + for (const terr of rules.terrain) { + check(`terrain ${terr.id} frame ${terr.frame} painted on ${name}`, tex?.frames.includes(terr.frame)); + } + } +} + +// --------------------------------------------------------------------------- +section('2c. View layering and first frame'); +// --------------------------------------------------------------------------- +{ + // TAWorldView and TAFx import no Phaser either, so the real render path runs here against + // a stub scene. This section exists because two bugs got past every data-level check and + // straight into the browser: the fog sheet drew UNDER the terrain (a Phaser Container + // renders in insertion order and never sorts by depth on its own), and the camera walked + // off the map before the first frame. Both are invisible to any test that only inspects + // simulation state. + const map = generateMap(rules, { seed: 123456, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 }); + const st = L.createMatch(rules, { seed: 123456, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); + const scene = makeStubScene(); + let view = null; + try { + view = new TAWorldView(scene, rules, artJson, st, 0); + const fx = new TAFx(scene, view.worldRoot, DEPTHS); + view.setFogEnabled(true); + const start = st.starts.find((x) => x.army === 0); + view.centerOn(start.x * st.tileSize, start.y * st.tileSize); + const out = view.render(0); + check('render() returns fx payloads', !!out && Array.isArray(out.projectiles) && Array.isArray(out.nanoLinks)); + + const order = view.worldRoot.list; + const lastChunk = Math.max(-1, ...order.map((o, i) => (o.type === 'renderTexture' ? i : -1))); + check('terrain chunks were painted', lastChunk >= 0); + check('fog draws ON TOP of terrain', order.indexOf(view.fogImg) > lastChunk, + `fog at ${order.indexOf(view.fogImg)}, last chunk at ${lastChunk}`); + check('fx layers are in the world container', + order.includes(fx.gUnder) && order.includes(fx.gOver)); + + const own = st.entities.find((e) => e.army === 0); + const foe = st.entities.find((e) => e.army === 1); + const ownSprite = view.sprites.get(own.id); + check('the player\'s starting unit has a sprite', !!ownSprite); + check('own unit draws on top of terrain', order.indexOf(ownSprite.img) > lastChunk); + check('own unit is visible through fog', ownSprite.img.visible); + check('the enemy is hidden at match start', !view.visibleToPlayer(foe)); + + // The Commander must be ON SCREEN when the match opens — an off-screen start reads to a + // player as "I have no units" and there is nothing they can do about it. + const cam = scene.cameras.main.worldView; + const onScreen = own.x >= cam.x && own.x <= cam.x + cam.width + && own.y >= cam.y && own.y <= cam.y + cam.height; + check('the opening camera frames the player\'s Commander', onScreen, + `unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`); + + // Wheel zoom must keep the world point under the cursor fixed, at any cursor position — + // including well away from the screen centre, which is where a centre-anchored zoom (or a + // correction computed from a stale camera matrix) visibly drifts. + for (const [fx2, fy2] of [[960, 540], [320, 220], [1700, 900], [40, 1040]]) { + for (const dir of [1, 1, -1, -1, -1, 1]) { + const beforePt = view.worldPoint(fx2, fy2); + const beforeZoom = view.zoom; + view.zoomBy(dir, fx2, fy2); + if (view.zoom === beforeZoom) continue; // already at the end of the ladder + const afterPt = view.worldPoint(fx2, fy2); + const drift = Math.hypot(afterPt.x - beforePt.x, afterPt.y - beforePt.y); + check(`zoom at (${fx2},${fy2}) keeps the cursor's world point fixed`, drift < 0.5, + `drifted ${drift.toFixed(1)}px`); + } + } + + // Depth bands must stay in the intended order. + check('bars draw above actors', DEPTHS.bars > DEPTHS.actor); + check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor)); + check('selection draws below actors', DEPTHS.selection < DEPTHS.actor); + } catch (e) { + check('the view layer builds and renders a frame', false, e.message); + } +} + +// --------------------------------------------------------------------------- +section('2d. Container hitbox lint'); +// --------------------------------------------------------------------------- +{ + // TAHud/TAScreens import Phaser directly, so they cannot be constructed here. This is a + // source lint instead, guarding one specific mistake that is invisible until a human tries + // to click something: a Phaser Container's origin is hard-coded to 0.5, so setInteractive() + // centres the hit area on the container's position. A child drawn from a top-left origin + // then sits half a button down-right of the region that actually receives the click. + const files = ['src/games/totalannihilation/TAHud.js', 'src/games/totalannihilation/TAScreens.js']; + for (const rel of files) { + const src = readFileSync(join(ROOT, rel), 'utf8'); + const lines = src.split('\n'); + let offenders = 0; + lines.forEach((line, i) => { + if (!/add\.container\(/.test(line)) return; + // Look at the block this container is built in. + const block = lines.slice(i, i + 26).join('\n'); + if (!/setInteractive\(/.test(block)) return; + if (/setOrigin\(\s*0\s*,\s*0\s*\)/.test(block)) offenders++; + }); + check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`, + offenders === 0, `${offenders} block(s)`); + } +} + +// --------------------------------------------------------------------------- +section('3. Economy fixtures'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 7, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 7, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const a = st.armies[0]; + a.mass = 0; a.energy = 0; + for (let i = 0; i < HZ; i++) L.tick(st, rules); + check('idle army banks its commander income', a.energy > 0 && a.mass > 0); + + // Building an Energy Generator takes exactly buildTime seconds at nominal build power, + // given enough stored resources that nothing stalls. + const st2 = L.createMatch(rules, { seed: 8, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const b = st2.armies[0]; + b.mass = 99999; b.energy = 99999; b.massCap = 99999; b.energyCap = 99999; + const cmd = st2.entities.find((e) => e.army === 0); + const gen = rules.buildingById.energygen; + let tx = worldToTileX(st2.nav, cmd.x) + 2, ty = worldToTileY(st2.nav, cmd.y); + while (!L.canPlaceAt(st2, rules, tx, ty, gen) && tx < st2.w - 4) tx++; + const r = L.issueOrder(st2, rules, { army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'energygen', tx, ty } }); + check('build order accepted', r.ok, r.error); + let ticks = 0; + while (ticks < 200 * HZ) { + b.mass = 99999; b.energy = 99999; // hold the economy open so only build power matters + L.tick(st2, rules); ticks++; + const site = st2.entities.find((e) => e.defId === 'energygen'); + if (site && !site.site) break; + } + const secs = ticks / HZ; + const expected = gen.buildTime * (rules.constants.buildPowerNominal / rules.unitById.commander.buildPower); + check('unstalled build takes buildTime seconds', Math.abs(secs - expected) <= expected * 0.35 + 2, + `${secs.toFixed(1)}s vs ${expected}s`); + + // Stall factor: halve the available mass flow and construction must slow, not stop. + const st3 = L.createMatch(rules, { seed: 9, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const c = st3.armies[0]; + c.mass = 0; c.energy = 0; + L.tick(st3, rules); + check('stall factors stay in [0,1]', c.stallM >= 0 && c.stallM <= 1 && c.stallE >= 0 && c.stallE <= 1); + check('buildEff is the binding factor', Math.abs(c.buildEff - Math.min(c.stallE, c.stallM)) < 1e-9); + + // Storage is capped and the overflow is discarded rather than silently banked. + const st4 = L.createMatch(rules, { seed: 10, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const d = st4.armies[0]; + d.energy = d.energyCap; d.mass = d.massCap; + for (let i = 0; i < HZ * 5; i++) L.tick(st4, rules); + check('stored energy never exceeds cap', d.energy <= d.energyCap + 1e-6); + check('stored mass never exceeds cap', d.mass <= d.massCap + 1e-6); + + // A Mass Generator on a metal patch must out-yield one on plain ground. + const mgen = rules.buildingById.massgen; + check('mass generator has a terrain multiplier', !!mgen.terrainMultiplier); + const multTerrain = rules.terrain.find((t) => t[mgen.terrainMultiplier] > 1); + check('some terrain carries that multiplier', !!multTerrain); +} + +// --------------------------------------------------------------------------- +section('4. Pathfinding'); +// --------------------------------------------------------------------------- +{ + const ts = rules.constants.tileSize; + const mk = (rows) => { + const w = rows[0].length, h = rows.length; + const terrain = new Uint8Array(w * h); + const wall = rules.terrainByCh['^'].index, open = rules.terrainByCh['.'].index; + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open; + return createNav(rules, { w, h, terrain }); + }; + const nav = mk([ + '..........', + '..######..', + '..#....#..', + '..#....#..', + '..######..', + '..........', + ]); + const mc = 'tread'; + check('tread is a declared movement class', mc in rules.moveClasses); + const at = (nav2, x, y) => y * nav2.w + x; + const p = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 9, 5)); + check('A* finds a route around an obstacle', !!p && p.length > 0); + const inside = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 4, 3)); + check('sealed region is unreachable', !inside); + + // Clearance: a wide unit must refuse a one-tile gap but accept a wide corridor. + const narrow = mk([ + '####.####', + '####.####', + '####.####', + ]); + const wide = mk([ + '##.....##', + '##.....##', + '##.....##', + ]); + const big = clearanceFor(rules.sizeClasses.large.radius, ts); + check('large clearance requirement is > 1 tile', big > 1); + check('large unit refuses a 1-wide corridor', !findPath(narrow, mc, big, 4, at(narrow, 4, 2))); + check('large unit accepts a wide corridor', !!findPath(wide, mc, big, at(wide, 3, 0), at(wide, 5, 2))); + + // String-pulling must never shortcut through a blocked tile. + const blocked = mk(['...', '.#.', '...']); + check('segmentClear rejects a line through a wall', + !segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 2.5 * ts)); + check('segmentClear accepts a clear line', + segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts)); +} + +// --------------------------------------------------------------------------- +section('5. Movement, separation and size classes'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 21, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + // Find a genuinely open tile with open neighbours — the middle of a generated map is as + // likely to be a lake as a field, and units shoved out of water prove nothing. + const openTile = (() => { + for (let y = 3; y < map.h - 3; y++) { + for (let x = 3; x < map.w - 3; x++) { + let ok = true; + for (let dy = -2; dy <= 2 && ok; dy++) { + for (let dx = -2; dx <= 2 && ok; dx++) { + if (!tileOk(st.nav, 'foot', 1, x + dx, y + dy)) ok = false; + } + } + if (ok) return { x, y }; + } + } + return { x: (map.w / 2) | 0, y: (map.h / 2) | 0 }; + })(); + const cx = openTile.x * st.tileSize + st.tileSize / 2; + const cy = openTile.y * st.tileSize + st.tileSize / 2; + check('found open ground for the packing fixture', tileOk(st.nav, 'foot', 1, openTile.x, openTile.y)); + + // The size-class requirement, asserted: three smalls share one tile, two mediums cannot. + const packed = (defId, n) => { + const s2 = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const made = []; + for (let i = 0; i < n; i++) { + const u = L.spawnUnit(s2, rules, 0, defId, cx + (i - n / 2) * 6, cy + (i % 2) * 6); + if (u) made.push(u); + } + for (let i = 0; i < 60; i++) L.tick(s2, rules); + const ts = s2.tileSize; + return made.filter((u) => Math.abs(u.x - cx) <= ts / 2 && Math.abs(u.y - cy) <= ts / 2).length; + }; + // The size-class contract is a statement about collision radii, so assert it there — and + // then confirm the emergent behaviour matches, which is the part a player actually sees. + const ts0 = rules.constants.tileSize; + const small = rules.sizeClasses.small, medium = rules.sizeClasses.medium, large = rules.sizeClasses.large; + check('small units are under half a tile wide', small.radius * 2 <= ts0 / 2, `${small.radius * 2}px`); + check('medium units are about one tile wide', + medium.radius * 2 > ts0 / 2 && medium.radius * 2 <= ts0, `${medium.radius * 2}px`); + check('large units span more than one tile', large.radius * 2 > ts0, `${large.radius * 2}px`); + check('large units claim a multi-tile footprint', (large.footprint ?? 1) > 1); + check('3 small units settle inside one tile', packed('infantry', 3) >= 3, `${packed('infantry', 3)}`); + check('3 medium units cannot settle inside one tile', packed('tank', 3) < 3, `${packed('tank', 3)}`); + + // Settled units must not overlap. + for (let i = 0; i < 12; i++) L.spawnUnit(st, rules, 0, 'tank', cx + (i % 4) * 30, cy + Math.floor(i / 4) * 30); + for (let i = 0; i < 120; i++) L.tick(st, rules); + const mine = st.entities.filter((e) => !e.dead && e.army === 0 && e.defId === 'tank'); + let worst = 0; + for (let i = 0; i < mine.length; i++) { + for (let j = i + 1; j < mine.length; j++) { + const overlap = (mine[i].radius + mine[j].radius) - Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y); + worst = Math.max(worst, overlap); + } + } + check('settled units barely overlap', worst < 12, `worst overlap ${worst.toFixed(1)}px`); + check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y))); +} + +// --------------------------------------------------------------------------- +section('6. Combat'); +// --------------------------------------------------------------------------- +{ + const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')); + raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders + const combatRules = compileRules(raw); + const map = generateMap(combatRules, { seed: 31, size: 'small', symmetry: 'mirror-x' }); + const cx = (map.w / 2) * combatRules.constants.tileSize, cy = (map.h / 2) * combatRules.constants.tileSize; + + const duel = (aId, na, bId, nb, sec = 150) => { + const st = L.createMatch(combatRules, { seed: 31, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (const e of st.entities) e.dead = true; + st.entities = []; + const line = (army, id, n, dx) => { + for (let i = 0; i < n; i++) { + L.spawnUnit(st, combatRules, army, id, + cx + dx - Math.sign(dx) * Math.floor(i / 10) * 40, cy - 180 + (i % 10) * 40); + } + }; + line(0, aId, na, -240); line(1, bId, nb, 240); + st.over = null; + for (const a of st.armies) a.alive = true; + const ids = (army) => st.entities.filter((e) => e.army === army && !e.dead).map((e) => e.id); + L.issueOrder(st, combatRules, { army: 0, unitIds: ids(0), order: { type: 'attackMove', x: cx + 240, y: cy } }); + L.issueOrder(st, combatRules, { army: 1, unitIds: ids(1), order: { type: 'attackMove', x: cx - 240, y: cy } }); + let t = 0; + for (; t < sec * HZ; t++) { + L.tick(st, combatRules); + const a0 = st.entities.some((e) => !e.dead && e.army === 0); + const a1 = st.entities.some((e) => !e.dead && e.army === 1); + if (!a0 || !a1) break; + } + const left = (a) => st.entities.filter((e) => !e.dead && e.army === a).length; + const hp = (a) => st.entities.filter((e) => !e.dead && e.army === a).reduce((s, e) => s + e.hp, 0); + return { a: left(0), b: left(1), hpA: hp(0), hpB: hp(1), secs: t / HZ }; + }; + const cost = (id) => combatRules.unitById[id].cost.mass + combatRules.unitById[id].cost.energy / 4; + const equal = (a, b, n = 8) => { + const nb = Math.max(1, Math.round(n * cost(a) / cost(b))); + const r = duel(a, n, b, nb); + // Score on surviving fraction of the force each side paid for, so a slow grind that the + // tanks are clearly winning counts as a win even if the clock runs out first. + r.fracA = r.a / n; r.fracB = r.b / nb; + return r; + }; + + // Ballistic shells travel 35px per 20Hz tick, so a point-sampled hit test tunnels straight + // through its target. Tanks doing real damage is the observable proof the sweep works. + const tvi = equal('tank', 'infantry'); + check('tanks beat equal-cost infantry', tvi.fracA > tvi.fracB, + `${tvi.a} tanks (${(tvi.fracA * 100) | 0}%) v ${tvi.b} infantry (${(tvi.fracB * 100) | 0}%)`); + const rvs = equal('rockettank', 'sniper'); + check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB, + `${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`); + // Rockets out-range and out-damage armour; that is what makes the roster a triangle + // rather than a ladder where one unit is simply the answer. + const rvt = equal('rockettank', 'tank'); + check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB, + `${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`); + + // Engagements must take long enough for reinforcement and composition to matter. When + // fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip. + check('an even engagement is not instant', tvi.secs > 5, `${tvi.secs.toFixed(1)}s`); + + // Damage fixtures. + const st = L.createMatch(combatRules, { seed: 32, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const victim = L.spawnUnit(st, combatRules, 1, 'tank', cx, cy); + const before = victim.hp; + L.applyDamage(st, combatRules, victim, 100, { army: 0, id: 0 }); + check('applyDamage subtracts exactly', Math.abs((before - victim.hp) - 100) < 1e-9); + const rifle = combatRules.weaponById.rifle; + check('rifle is strong vs infantry, weak vs medium', + armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3); + const rocket = combatRules.weaponById.rocketpod; + check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3); + + // Commander death explosion. + const dth = combatRules.unitById.commander.deathExplosion; + check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500); +} + +// --------------------------------------------------------------------------- +section('7. Fog of war'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 41, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 41, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const mine = st.entities.find((e) => e.army === 0); + const theirs = st.entities.find((e) => e.army === 1); + L.computeVision(st, rules); + check('own units are always visible', L.isVisibleTo(st, 0, mine)); + check('a distant enemy starts hidden', !L.isVisibleTo(st, 0, theirs)); + + // Explored is sticky; visible is not. + const cell = st.tileSize * 2; + const vx = Math.floor(mine.x / cell), vy = Math.floor(mine.y / cell); + const idx = vy * st.visW + vx; + check('own tile is explored', st.armies[0].explored[idx] === 1); + L.spawnUnit(st, rules, 0, 'jeep', theirs.x, theirs.y); + L.computeVision(st, rules); + check('scouting reveals the enemy', L.isVisibleTo(st, 0, theirs)); + + // The AI must be fog-limited too — it may not target something it cannot see. + const st2 = L.createMatch(rules, { seed: 42, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < HZ * 3; i++) { runAI(rules, st2, 0, { skill: 5 }); L.tick(st2, rules); } + const mem = st2.aiMem?.[0]; + const cheated = (mem?.knownEnemies ?? []).filter((k) => { + const e = L.entityById(st2, k.id); + return e && !L.isVisibleTo(st2, 0, e) && k.tick === st2.tick; + }); + check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`); +} + +// --------------------------------------------------------------------------- +section('8. Map generation'); +// --------------------------------------------------------------------------- +{ + const seeds = QUICK ? 24 : 120; + let symMismatch = 0, badStarts = 0, poorMetal = 0, unreachable = 0; + const sizes = Object.keys(rules.skirmish.sizes); + for (let i = 0; i < seeds; i++) { + const size = sizes[i % sizes.length]; + const sym = rules.skirmish.symmetries[i % rules.skirmish.symmetries.length]; + const map = generateMap(rules, { seed: 5000 + i * 13, size, symmetry: sym, armies: 2 }); + if (map.starts.length !== 2) { badStarts++; continue; } + + if (sym === 'mirror-x') { + for (let y = 0; y < map.h; y++) { + for (let x = 0; x < map.w; x++) { + if (map.terrain[y * map.w + x] !== map.terrain[y * map.w + (map.w - 1 - x)]) { symMismatch++; y = map.h; break; } + } + } + } + // Every start needs metal within reach or its economy can never leave the ground. + const mgen = rules.buildingById.massgen; + for (const s of map.starts) { + let spots = 0; + const R = rules.skirmish.gen.metalSpotRadiusTiles; + for (let y = Math.max(0, s.y - R); y < Math.min(map.h, s.y + R); y++) { + for (let x = Math.max(0, s.x - R); x < Math.min(map.w, s.x + R); x++) { + if (rules.terrain[map.terrain[y * map.w + x]][mgen.terrainMultiplier] > 1) spots++; + } + } + if (spots < 4) poorMetal++; + } + // And the two starts must be mutually reachable by a medium ground unit. + const nav = createNav(rules, map); + const clr = clearanceFor(rules.sizeClasses.medium.radius, rules.constants.tileSize); + const path = findPath(nav, 'tread', clr, + map.starts[0].y * map.w + map.starts[0].x, map.starts[1].y * map.w + map.starts[1].x); + if (!path) unreachable++; + } + check('every generated map places both starts', badStarts === 0, `${badStarts} bad`); + check('mirror-x maps are exactly symmetric', symMismatch === 0, `${symMismatch} mismatches`); + check('every start has metal nearby', poorMetal === 0, `${poorMetal} starved starts`); + check('starts are mutually reachable', unreachable === 0, `${unreachable} unreachable`); +} + +// --------------------------------------------------------------------------- +section('9. Campaign data'); +// --------------------------------------------------------------------------- +{ + const missions = campaign.missions ?? []; + check('campaign has missions', missions.length > 0); + const OBJECTIVES = new Set(['destroyAll', 'destroyCommander', 'survive', 'holdArea', 'reachArea', 'protect', 'buildCount']); + missions.forEach((m, i) => { + let map = null; + try { map = decodeMap(rules, m.map); } catch (e) { /* reported below */ } + check(`${m.id} terrain decodes`, !!map); + if (!map) return; + check(`${m.id} declares both starts`, (map.starts ?? []).length >= 2); + check(`${m.id} theme exists`, !!artJson.themes?.[m.theme], m.theme); + check(`${m.id} player army exists`, !!rules.armyById[m.playerArmy]); + check(`${m.id} player commander exists`, !!rules.commanderById[m.playerCommander]); + check(`${m.id} objective is a known type`, OBJECTIVES.has(m.objective?.type), m.objective?.type); + for (const e of m.enemies ?? []) { + check(`${m.id} enemy commander exists`, !!rules.commanderById[e.commander], e.commander); + const sk = e.aiProfile?.skill; + check(`${m.id} enemy skill is 1-5`, sk >= 1 && sk <= 5, String(sk)); + } + for (const b of map.buildings ?? []) { + check(`${m.id} prebuilt ${b.type} exists`, !!rules.buildingById[b.type], b.type); + check(`${m.id} prebuilt ${b.type} is on the map`, b.tx >= 0 && b.ty >= 0 && b.tx < map.w && b.ty < map.h); + } + for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) { + check(`${m.id} speaker ${line.speaker} exists`, + (opponents.opponents ?? []).some((o) => o.id === line.speaker), line.speaker); + } + // Missions must get harder, or the unlock order is meaningless. + if (i > 0) { + const prev = missions[i - 1].enemies?.[0]?.aiProfile?.skill ?? 0; + check(`${m.id} is no easier than the one before`, (m.enemies?.[0]?.aiProfile?.skill ?? 0) >= prev); + } + }); +} + +// --------------------------------------------------------------------------- +section('10. Serialization and determinism'); +// --------------------------------------------------------------------------- +{ + const map = generateMap(rules, { seed: 51, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 51, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < HZ * 20; i++) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); L.tick(st, rules); } + const blob = L.serialize(st); + const back = L.deserialize(rules, blob); + check('serialize round-trips to the same hash', L.hashState(back) === L.hashState(st)); + check('serialized save is a sane size', blob.length < 400000, `${blob.length} bytes`); + + const run = () => { + const m2 = generateMap(rules, { seed: 52, size: 'small', symmetry: 'mirror-x' }); + const s2 = L.createMatch(rules, { seed: 52, map: m2, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < HZ * 30; i++) { runAI(rules, s2, 0, { skill: 4 }); runAI(rules, s2, 1, { skill: 2 }); L.tick(s2, rules); } + return L.hashState(s2); + }; + check('same seed replays identically', run() === run()); + + // The step() harness must produce the same result as driving tick() directly. + const m3 = generateMap(rules, { seed: 53, size: 'small', symmetry: 'mirror-x' }); + const byTick = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < 100; i++) L.tick(byTick, rules); + const byStep = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + for (let i = 0; i < 100; i++) L.step(byStep, rules, rules.stepMs, 4); + check('step() and tick() agree', L.hashState(byStep) === L.hashState(byTick)); +} + +// --------------------------------------------------------------------------- +section('11. Skirmish soak'); +// --------------------------------------------------------------------------- +{ + const games = QUICK ? 8 : 24; + let decided = 0, negative = 0, nonFinite = 0, overCap = 0, peakUnits = 0; + let totalTickMs = 0, ticks = 0, worstTick = 0; + for (let g = 0; g < games; g++) { + const map = generateMap(rules, { seed: 6000 + g * 17, size: g % 2 ? 'medium' : 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 6000 + g, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const cap = rules.constants.unitCapPerArmy; + while (!st.over && st.tick < 1200 * HZ) { + runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); + const t0 = process.hrtime.bigint(); + L.tick(st, rules); + const ms = Number(process.hrtime.bigint() - t0) / 1e6; + totalTickMs += ms; ticks++; worstTick = Math.max(worstTick, ms); + for (const a of st.armies) if (a.mass < -1e-6 || a.energy < -1e-6) negative++; + if (st.tick % 200 === 0) { + for (const e of st.entities) if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) nonFinite++; + for (let i = 0; i < st.armies.length; i++) { + const n = st.entities.filter((e) => !e.dead && e.army === i && !e.isBuilding).length; + peakUnits = Math.max(peakUnits, n); + if (n > cap) overCap++; + } + } + } + if (st.over && st.over.winner >= 0) decided++; + } + const avg = totalTickMs / Math.max(1, ticks); + console.log(` ${decided}/${games} decided · peak ${peakUnits} units/army · tick avg ${avg.toFixed(2)}ms worst ${worstTick.toFixed(1)}ms`); + check('most games reach a decision', decided >= Math.ceil(games * 0.85), `${decided}/${games}`); + check('resources never go negative', negative === 0, `${negative}`); + check('positions stay finite', nonFinite === 0, `${nonFinite}`); + check('unit cap is respected', overCap === 0, `${overCap} breaches`); + // Average is the real budget signal at 20Hz (50ms per tick). The worst case is allowed + // more headroom because one slow tick — a burst of path requests, or a GC landing on it — + // costs a single dropped frame, not a stall; it is printed above either way. + check('average tick is well inside the 20Hz budget', avg < 8, `avg ${avg.toFixed(2)}ms`); + check('no tick blows the frame budget outright', worstTick < 120, `worst ${worstTick.toFixed(1)}ms`); +} + +// --------------------------------------------------------------------------- +section('12. AI skill ladder'); +// --------------------------------------------------------------------------- +{ + const pairs = QUICK ? 6 : 16; + // Each seed is played BOTH ways round so that side advantage — turn order, spawn corner, + // commander profile — cancels exactly instead of being assumed away. Measured over 32 + // mirrored pairs the ladder runs 5v1 ≈ 84%, 3v1 ≈ 81%, 5v3 ≈ 59%; the bars below sit far + // enough below those to be stable at this sample size. + const ladder = (sa, sb) => { + let win = 0, dec = 0; + for (let i = 0; i < pairs; i++) { + for (const flip of [false, true]) { + const map = generateMap(rules, { seed: 7000 + i * 7, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 7000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const p0 = flip ? sb : sa, p1 = flip ? sa : sb; + while (!st.over && st.tick < 1200 * HZ) { + runAI(rules, st, 0, { skill: p0 }); runAI(rules, st, 1, { skill: p1 }); + L.tick(st, rules); + } + if (st.over && st.over.winner >= 0) { + dec++; + if (st.over.winner === (flip ? 1 : 0)) win++; + } + } + } + return dec ? win / dec : 0; + }; + const hi = ladder(5, 1); + console.log(` skill 5 vs skill 1: ${(hi * 100).toFixed(1)}%`); + check('skill 5 beats skill 1 decisively', hi >= 0.72, `${(hi * 100).toFixed(1)}%`); + if (!QUICK) { + const mid = ladder(4, 2); + console.log(` skill 4 vs skill 2: ${(mid * 100).toFixed(1)}%`); + check('skill 4 beats skill 2', mid >= 0.52, `${(mid * 100).toFixed(1)}%`); + } +} + +// --------------------------------------------------------------------------- +section('13. Campaign winnability'); +// --------------------------------------------------------------------------- +{ + // A skill-5 bot plays the PLAYER's side through the same issueOrder API a human uses. This + // is the entire reason the order API is shared, and it is the only way to know a mission is + // actually beatable rather than merely plausible. + const runs = QUICK ? 2 : 4; + for (const m of campaign.missions ?? []) { + let wins = 0, played = 0; + for (let r = 0; r < runs; r++) { + const map = decodeMap(rules, m.map); + const st = L.createMatch(rules, { + seed: (m.seed ?? 1) + r * 101, map, + armies: [ + { armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true }, + ...(m.enemies ?? []).map((e) => ({ + armyId: e.army, commanderId: e.commander, + aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null, + })), + ], + }); + (m.startResources ?? []).forEach((res, i) => { + if (!st.armies[i]) return; + st.armies[i].mass = res.mass ?? st.armies[i].mass; + st.armies[i].energy = res.energy ?? st.armies[i].energy; + }); + while (!st.over && st.tick < 1500 * HZ) { + runAI(rules, st, 0, { skill: 5 }); + for (let i = 1; i < st.armies.length; i++) { + const e = m.enemies?.[i - 1]; + runAI(rules, st, i, { skill: e?.aiProfile?.skill ?? 3, ...(e?.aiProfile ?? {}) }); + } + L.tick(st, rules); + } + played++; + if (st.over && st.over.winner === 0) wins++; + } + const rate = played ? wins / played : 0; + console.log(` ${m.id} ${m.name.padEnd(16)} bot wins ${wins}/${played}`); + check(`${m.id} is winnable by a skill-5 bot`, rate >= 0.5, `${wins}/${played}`); + } +} + +// --------------------------------------------------------------------------- +console.log(`\n${'─'.repeat(60)}`); +if (failures.length) { + console.log(`FAILED — ${pass} checks passed, ${failures.length} failed:`); + for (const f of failures) console.log(` ✗ ${f}`); + process.exit(1); +} +console.log(`OK — all ${pass} checks passed${QUICK ? ' (quick)' : ''}.`);