diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 3d219da..2d2456b 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 5e5645a..ad12092 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/data/dungeonboss-artwork.json b/data/dungeonboss-artwork.json new file mode 100644 index 0000000..fb90a8d --- /dev/null +++ b/data/dungeonboss-artwork.json @@ -0,0 +1,51 @@ +{ + "_readme": [ + "Drop-in art for Dungeon Boss. All cards render fully procedurally (frame,", + "border, name, text, treasure icons) until a sheet path is filled in below;", + "supplied art fills only the card's art window inside the procedural frame,", + "so illustrations need no borders or text of their own.", + "To add art for a category:", + " 1. Drop the sheet PNG into /assets/images/ (row-major frame order).", + " 2. Set that sheet's path below (frameWidth/frameHeight already match the", + " art-window aspect the renderer uses).", + " 3. Map each card id to its 0-based frame index in the matching map below.", + "Sheets: rooms are landscape windows; heroes/spells portrait; bosses larger", + "portrait. Ids not present in a map (or maps left empty) stay procedural." + ], + "roomSheet": { "key": "dungeonboss-rooms", "path": "", "frameWidth": 300, "frameHeight": 200 }, + "heroSheet": { "key": "dungeonboss-heroes", "path": "", "frameWidth": 220, "frameHeight": 300 }, + "spellSheet": { "key": "dungeonboss-spells", "path": "", "frameWidth": 220, "frameHeight": 300 }, + "bossSheet": { "key": "dungeonboss-bosses", "path": "", "frameWidth": 260, "frameHeight": 360 }, + + "rooms": { + "darkaltar": 0, "opengrave": 1, "specterssanctum": 2, "succubusspa": 3, + "dracolichlair": 4, "vampirebordello": 5, "goblinarmory": 6, "golemfactory": 7, + "minotaursmaze": 8, "neanderthalcave": 9, "beastmenagerie": 10, "monstersballroom": 11, + "brainsuckerhive": 12, "darklaboratory": 13, "hauntedlibrary": 14, "witchskitchen": 15, + "allseeingeye": 16, "ligersden": 17, "bottomlesspit": 18, "boulderramp": 19, + "dizzygashallway": 20, "jackpotstash": 21, "recyclingcenter": 22, "thecrushinator": 23, + "centipedetunnel": 24, "constructionzone": 25, "dragonhatchery": 26, "mimicvault": 27, + "monstrousmonument": 28, "torturechamber": 29, "zombieprison": 30 + }, + "spells": { + "annihilator": 0, "assassin": 1, "cavein": 2, "counterspell": 3, "exhaustion": 4, + "fear": 5, "freeze": 6, "giantsize": 7, "jeopardy": 8, "koboldstrike": 9, + "motivation": 10, "princessinperil": 11, "soulharvest": 12, "teleportation": 13, + "trepidation": 14, "zombieattack": 15 + }, + "heroes": { + "nick": 0, "pugi": 1, "acacia": 2, "charles": 3, "delatorious": 4, "romero": 5, + "boden": 6, "jarek": 7, "firesbreath": 8, "samuraitom": 9, "johnny": 10, "crystolalan": 11, + "tieg": 12, "brandork": 13, "mitchell": 14, "kalish": 15, "dartteon": 16, "koey": 17, + "joman": 18, "lance": 19, "jesta": 20, "kins": 21, "digby": 22, "jerome": 23, + "fool": 24, + "katelyn": 25, "kerberos": 26, "vanette": 27, "jejune": 28, + "frankov": 29, "nate": 30, "antonius": 31, "asmor": 32, + "tempros": 33, "chiakang": 34, "terric": 35, "wayward": 36, + "hya": 37, "blackbeard": 38, "cecil": 39, "wallbanger": 40 + }, + "bosses": { + "draculord": 0, "cleopatra": 1, "kingcroak": 2, "xyzax": 3, + "cerebellus": 4, "seducia": 5, "gorgona": 6, "robobo": 7 + } +} diff --git a/data/swdbg-artwork.json b/data/swdbg-artwork.json new file mode 100644 index 0000000..deea3cc --- /dev/null +++ b/data/swdbg-artwork.json @@ -0,0 +1,42 @@ +{ + "_readme": [ + "Drop-in art for Star Wars Deckbuilder. All cards render fully procedurally", + "(faction frame, cost badge, stats, name, rules text) until a sheet path is", + "filled in below; supplied art fills only the card's art window inside the", + "procedural frame, so illustrations need no borders or text of their own.", + "To add art:", + " 1. Drop the sheet PNG into /assets/images/ (row-major frame order).", + " 2. Set that sheet's path below (frameWidth/frameHeight already match the", + " art-window aspect the renderer uses: cards landscape, bases portrait).", + " 3. Map each card id to its 0-based frame index in the matching map below.", + "Ids not present in a map (or maps left empty) stay procedural." + ], + "cardSheet": { "key": "swdbg-cards-art", "path": "", "frameWidth": 300, "frameHeight": 230 }, + "baseSheet": { "key": "swdbg-bases-art", "path": "", "frameWidth": 220, "frameHeight": 300 }, + + "cards": { + "allianceshuttle": 0, "rebeltrooper": 1, "templeguardian": 2, + "imperialshuttle": 3, "stormtrooper": 4, "inquisitor": 5, + "outerrimpilot": 6, + "tiefighter": 7, "scouttrooper": 8, "deathtrooper": 9, "tiebomber": 10, + "tieinterceptor": 11, "atst": 12, "atat": 13, "generalveers": 14, + "grandmofftarkin": 15, "directorkrennic": 16, "darthvader": 17, + "emperorpalpatine": 18, "gozanticruiser": 19, "imperialcarrier": 20, + "stardestroyer": 21, + "rebelpathfinder": 22, "ywing": 23, "snowspeeder": 24, "durosspy": 25, + "xwing": 26, "rebelcommando": 27, "cassianandor": 28, "k2so": 29, + "chirrutimwe": 30, "jynerso": 31, "chewbacca": 32, "hansolo": 33, + "princessleia": 34, "lukeskywalker": 35, "blockaderunner": 36, + "hammerheadcorvette": 37, "medicalfrigate": 38, "moncalamaricruiser": 39, + "millenniumfalcon": 40, + "z95headhunter": 41, "jawascavenger": 42, "keldormystic": 43, + "rodiangunslinger": 44, "hwk290": 45, "twileksmuggler": 46, + "fangfighter": 47, "quarrenmercenary": 48, "bossk": 49, "ig88": 50, + "bobafett": 51, "jabbathehutt": 52, "crocruiser": 53, "khetanna": 54 + }, + + "bases": { + "dantooine": 0, "hoth": 1, "sullust": 2, "moncala": 3, "yavin4": 4, + "lothal": 5, "corellia": 6, "coruscant": 7, "deathstar": 8, "endor": 9 + } +} diff --git a/data/swdbg-cards.json b/data/swdbg-cards.json new file mode 100644 index 0000000..adee222 --- /dev/null +++ b/data/swdbg-cards.json @@ -0,0 +1,295 @@ +{ + "_readme": [ + "Star Wars: The Deckbuilding Game — full card data. This file is the single", + "source of truth for every card: edit stats/text here and the game engine", + "picks the changes up on the next page load (no rebuild needed).", + "", + "RULES DATA CAVEAT: the rules engine follows the official rulebook exactly,", + "but individual card stats/abilities below were reconstructed from memory and", + "public previews — verify them against the physical cards and edit freely.", + "", + "CARD FIELDS:", + " id unique lowercase key (do not change once art is mapped)", + " name display name", + " faction 'empire' | 'rebel' | 'neutral'", + " type 'unit' | 'capital' (capital ships stay in play, have hp)", + " unique true = 1-of, may be referenced by other cards", + " traits flavor tags referenced by abilities (Fighter, Trooper, ...)", + " cost purchase cost in resources", + " attack / resources / force printed values gained when played", + " hp capital ships only — damage needed to destroy it in play", + " count copies in the 90-card galaxy deck", + " target attack needed to bounty-hunt/sabotage it in the row (faction", + " cards only; neutral cards and capital ships use null)", + " reward what the defeater gains: {resources, force, draw} or null", + " ability machine-readable effect (see OPS below) or null", + " text display-only rules text — keep in sync with `ability`", + "", + "ABILITY SHAPE: { timing, op, ...params, cost?, requiresForce? }", + " timing 'action' = use once per turn while in play (the default)", + " 'constant' = always on while in play ('While ...')", + " 'onPlay' = triggers when the card is played", + " 'onBounty' = triggers when you defeat a galaxy-row card", + " 'onReveal' = bases only: when the base is revealed", + " cost { exileSelf: true } and/or { exileFromHand: 1 } paid before the op", + " requiresForce: true = usable only while the Force is with you", + " nForce / amountForce = upgraded number while the Force is with you", + "", + "SUPPORTED OPS (the engine understands exactly these):", + " draw{n,nForce} draw cards", + " gainResources{n,nForce} add resources to your pool this turn", + " gainForce{n} move the Force marker toward you", + " gainAttack{n,nForce} this card gains attack this turn", + " damageBase{n,nForce} damage enemy base directly (ignores capitals)", + " repairBase{n} remove damage from your base", + " oppDiscard{n,randomForce} opponent discards (random while Force is w/ you)", + " discardRow{} discard a card from the galaxy row (no reward)", + " destroyCapital{} destroy a capital ship (in play or in the row)", + " exileCards{n,optional} exile up to n cards from your hand/discard", + " freePurchase{faction,maxCost,forceTopdeck} take a row card for free", + " recoverDiscard{filter} return a card from your discard to your hand", + " lookTop{mayDiscard} look at top of galaxy deck, optionally discard", + " revealTop{} reveal top of galaxy deck; if it matches your", + " faction gain 1 resource, if enemy you may", + " discard it, if neutral nothing happens", + " spyChoice{} opponent chooses: discard 1 OR you gain 1 Force", + " choose{options:[a,b]} pick one of two sub-abilities", + " traitBoost{trait,amount} constant: your units with trait get +attack", + " rowBonus{amount} constant: +attack when attacking the galaxy row", + " selfBoostWhileForce{amount} constant: +attack while the Force is with you", + " selfBoostWithTrait{trait,amount} constant: +attack while you have another", + " card with the trait in play", + " capitalDiscount{n} constant (bases): your capital ships cost less", + " onPlayTraitAttack{trait,amount} constant (bases): played units with the", + " trait gain +attack", + "", + "BASES: hp, starting (true = begin the game with it), ability (onReveal /", + "constant / action ops above), text. Beginner game uses these five per side;", + "add more entries to play Full Galaxy mode." + ], + + "meta": { + "basesToWin": 3, + "handSize": 5, + "galaxyRowSize": 6, + "forceSpaces": 3, + "forceStartsWith": "rebel" + }, + + "starters": { + "rebel": [ + { "id": "allianceshuttle", "count": 7 }, + { "id": "rebeltrooper", "count": 2 }, + { "id": "templeguardian", "count": 1 } + ], + "empire": [ + { "id": "imperialshuttle", "count": 7 }, + { "id": "stormtrooper", "count": 2 }, + { "id": "inquisitor", "count": 1 } + ] + }, + + "starterCards": [ + { "id": "allianceshuttle", "name": "Alliance Shuttle", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 0, "attack": 0, "resources": 1, "force": 0, "target": null, "reward": null, "ability": null, + "text": "The workhorse of the Rebellion's supply lines." }, + { "id": "rebeltrooper", "name": "Rebel Trooper", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 0, "attack": 2, "resources": 0, "force": 0, "target": null, "reward": null, "ability": null, + "text": "Rebels don't wait for the odds to look good." }, + { "id": "templeguardian", "name": "Temple Guardian", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Guardian"], "cost": 0, "attack": 1, "resources": 0, "force": 1, "target": null, "reward": null, "ability": null, + "text": "The Force is with me, and I am one with the Force." }, + { "id": "imperialshuttle", "name": "Imperial Shuttle", "faction": "empire", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 0, "attack": 0, "resources": 1, "force": 0, "target": null, "reward": null, "ability": null, + "text": "Cleared for landing on platform 327." }, + { "id": "stormtrooper", "name": "Stormtrooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 0, "attack": 2, "resources": 0, "force": 0, "target": null, "reward": null, "ability": null, + "text": "Precision aim is a myth. Overwhelming numbers are not." }, + { "id": "inquisitor", "name": "Inquisitor", "faction": "empire", "type": "unit", "unique": false, "traits": ["Sith"], "cost": 0, "attack": 1, "resources": 0, "force": 1, "target": null, "reward": null, "ability": null, + "text": "There are things far worse than death." } + ], + + "outerRimPilot": { + "id": "outerrimpilot", "name": "Outer Rim Pilot", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Pilot"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 10, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true, "cost": { "exileSelf": true } }, + "text": "Exile Outer Rim Pilot from play: exile a card from your hand or discard pile." + }, + + "cards": [ + { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 1, "resources": 0, "force": 0, "count": 5, "target": 2, "reward": { "resources": 1 }, + "ability": { "timing": "constant", "op": "selfBoostWithTrait", "trait": "Fighter", "amount": 1 }, + "text": "TIE Fighter gains 1 attack while you have another Fighter in play." }, + { "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1 }, + "ability": { "timing": "action", "op": "revealTop" }, + "text": "Reveal the top card of the galaxy deck. If it is an Empire card, gain 1 resource. If it is a Rebel card, you may discard it." }, + { "id": "deathtrooper", "name": "Death Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 4, "reward": { "resources": 2 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "tiebomber", "name": "TIE Bomber", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1, "force": 1 }, + "ability": { "timing": "action", "op": "damageBase", "n": 1, "nForce": 2 }, + "text": "Deal 1 damage to the enemy base (2 damage instead while the Force is with you)." }, + { "id": "tieinterceptor", "name": "TIE Interceptor", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 2, "target": 4, "reward": { "resources": 2 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 1 }, + "text": "TIE Interceptor gains 1 attack while the Force is with you." }, + { "id": "atst", "name": "AT-ST", "faction": "empire", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 2, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": null, + "text": "The chicken walker: ridiculous nickname, unfunny results." }, + { "id": "atat", "name": "AT-AT", "faction": "empire", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 6, "attack": 6, "resources": 0, "force": 0, "count": 1, "target": 7, "reward": { "resources": 3, "force": 1 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "generalveers", "name": "General Veers", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 4, "attack": 2, "resources": 1, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": { "timing": "constant", "op": "traitBoost", "trait": "Vehicle", "amount": 2 }, + "text": "Your Vehicles gain 2 attack while General Veers is in play." }, + { "id": "grandmofftarkin", "name": "Grand Moff Tarkin", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 6, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": 7, "reward": { "resources": 3, "force": 1 }, + "ability": { "timing": "action", "op": "discardRow" }, + "text": "You may discard a card from the galaxy row." }, + { "id": "directorkrennic", "name": "Director Krennic", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 5, "attack": 3, "resources": 2, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "nBase": { "base": "deathstar", "n": 2 } }, + "text": "Draw 1 card (2 cards instead if your base is the Death Star)." }, + { "id": "darthvader", "name": "Darth Vader", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "damageBase", "n": 2, "requiresForce": true }, + "text": "If the Force is with you, deal 2 damage to the enemy base." }, + { "id": "emperorpalpatine", "name": "Emperor Palpatine", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 3, "resources": 0, "force": 3, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "choose", "options": [ { "op": "draw", "n": 2 }, { "op": "oppDiscard", "n": 1, "randomForce": true } ] }, + "text": "Choose one: draw 2 cards, or your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "gozanticruiser", "name": "Gozanti Cruiser", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 3, "attack": 0, "resources": 1, "force": 0, "hp": 3, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "A slow freighter with just enough guns to matter." }, + { "id": "imperialcarrier", "name": "Imperial Carrier", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 5, "attack": 0, "resources": 1, "force": 0, "hp": 5, "count": 2, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "traitBoost", "trait": "Fighter", "amount": 1 }, + "text": "Your Fighters gain 1 attack while Imperial Carrier is in play." }, + { "id": "stardestroyer", "name": "Star Destroyer", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 7, "attack": 3, "resources": 1, "force": 0, "hp": 7, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "The Imperial Navy's argument, delivered at length." }, + + { "id": "rebelpathfinder", "name": "Rebel Pathfinder", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 1, "attack": 1, "resources": 1, "force": 0, "count": 3, "target": 2, "reward": { "resources": 1 }, + "ability": null, + "text": "First in, last out, rarely thanked." }, + { "id": "ywing", "name": "Y-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": 2, "reward": { "resources": 1 }, + "ability": { "timing": "action", "op": "damageBase", "n": 1 }, + "text": "Deal 1 damage to the enemy base." }, + { "id": "snowspeeder", "name": "Snowspeeder", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": 3, "reward": { "resources": 1 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 1 }, + "text": "Snowspeeder gains 1 attack while the Force is with you." }, + { "id": "durosspy", "name": "Duros Spy", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Spy"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": 2, "reward": { "force": 1 }, + "ability": { "timing": "action", "op": "spyChoice" }, + "text": "Your opponent chooses one: they discard a card from their hand, or you gain 1 Force." }, + { "id": "xwing", "name": "X-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1, "force": 1 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresForce": true }, + "text": "If the Force is with you, draw a card." }, + { "id": "rebelcommando", "name": "Rebel Commando", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 3, "target": 3, "reward": { "resources": 2 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "cassianandor", "name": "Cassian Andor", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper", "Spy"], "cost": 4, "attack": 3, "resources": 1, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": { "timing": "action", "op": "lookTop", "mayDiscard": true }, + "text": "Look at the top card of the galaxy deck. You may discard it." }, + { "id": "k2so", "name": "K-2SO", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Droid"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, + { "id": "chirrutimwe", "name": "Chirrut Îmwe", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Guardian"], "cost": 3, "attack": 2, "resources": 0, "force": 2, "count": 1, "target": 4, "reward": { "force": 2 }, + "ability": { "timing": "action", "op": "gainForce", "n": 1 }, + "text": "Gain 1 Force." }, + { "id": "jynerso", "name": "Jyn Erso", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper"], "cost": 5, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": 6, "reward": { "resources": 2, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresForce": true }, + "text": "If the Force is with you, draw a card." }, + { "id": "chewbacca", "name": "Chewbacca", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 5, "attack": 4, "resources": 1, "force": 0, "count": 1, "target": 6, "reward": { "resources": 2, "force": 2 }, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": "unique" }, + "text": "Return a unique card from your discard pile to your hand." }, + { "id": "hansolo", "name": "Han Solo", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 7, "attack": 3, "resources": 2, "force": 0, "count": 1, "target": 8, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1 }, + "text": "Draw a card." }, + { "id": "princessleia", "name": "Princess Leia", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 6, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "freePurchase", "faction": "rebel", "forceTopdeck": true }, + "text": "Purchase a Rebel card from the galaxy row for free. If the Force is with you, place that card on top of your deck." }, + { "id": "lukeskywalker", "name": "Luke Skywalker", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Jedi"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 8, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "gainAttack", "n": 2, "requiresForce": true }, + "text": "If the Force is with you, Luke Skywalker gains 2 attack." }, + { "id": "blockaderunner", "name": "Blockade Runner", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "Though designed as a diplomatic courier, the Corellian CR90 corvette is often used to run planetary blockades." }, + { "id": "hammerheadcorvette", "name": "Hammerhead Corvette", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 5, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "destroyCapital", "cost": { "exileSelf": true } }, + "text": "Exile Hammerhead Corvette from play: destroy a capital ship." }, + { "id": "medicalfrigate", "name": "Medical Frigate", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 6, "attack": 0, "resources": 1, "force": 0, "hp": 5, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "repairBase", "n": 1 }, + "text": "Repair 1 damage from your base." }, + { "id": "moncalamaricruiser", "name": "Mon Calamari Cruiser", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 8, "attack": 3, "resources": 1, "force": 0, "hp": 8, "count": 1, "target": null, "reward": null, + "ability": null, + "text": "The backbone of the Rebel fleet, crewed by the galaxy's finest shipwrights." }, + { "id": "millenniumfalcon", "name": "Millennium Falcon", "faction": "rebel", "type": "capital", "unique": true, "traits": ["Capital Ship", "Freighter"], "cost": 7, "attack": 2, "resources": 1, "force": 0, "hp": 6, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": "unique" }, + "text": "Return a unique card from your discard pile to your hand." }, + + { "id": "z95headhunter", "name": "Z-95 Headhunter", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 1, "resources": 1, "force": 0, "count": 4, "target": null, "reward": null, + "ability": null, + "text": "Obsolete everywhere except where it counts." }, + { "id": "jawascavenger", "name": "Jawa Scavenger", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Scavenger"], "cost": 1, "attack": 0, "resources": 1, "force": 0, "count": 3, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true }, + "text": "Exile a card from your hand or discard pile. Utinni!" }, + { "id": "keldormystic", "name": "Kel Dor Mystic", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Mystic"], "cost": 2, "attack": 0, "resources": 0, "force": 2, "count": 3, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true }, + "text": "Exile a card from your hand or discard pile." }, + { "id": "rodiangunslinger", "name": "Rodian Gunslinger", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Bounty Hunter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "rowBonus", "amount": 2 }, + "text": "Rodian Gunslinger gains 2 attack while attacking a target in the galaxy row." }, + { "id": "hwk290", "name": "HWK-290", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Freighter"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "lookTop", "mayDiscard": true }, + "text": "Look at the top card of the galaxy deck. You may discard it." }, + { "id": "twileksmuggler", "name": "Twi'lek Smuggler", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Smuggler"], "cost": 3, "attack": 0, "resources": 3, "force": 0, "count": 3, "target": null, "reward": null, + "ability": null, + "text": "No manifest, no questions, no refunds." }, + { "id": "fangfighter", "name": "Fang Fighter", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "Mandalorian engineering does not forgive." }, + { "id": "quarrenmercenary", "name": "Quarren Mercenary", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Mercenary"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 3, "target": null, "reward": null, + "ability": null, + "text": "Loyal to the last credit." }, + { "id": "bossk", "name": "Bossk", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 4, "attack": 3, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onBounty", "op": "gainResources", "n": 2 }, + "text": "When you defeat a card in the galaxy row, gain 2 resources." }, + { "id": "ig88", "name": "IG-88", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Droid", "Bounty Hunter"], "cost": 5, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "rowBonus", "amount": 2 }, + "text": "IG-88 gains 2 attack while attacking a target in the galaxy row." }, + { "id": "bobafett", "name": "Boba Fett", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 6, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onBounty", "op": "draw", "n": 1 }, + "text": "When you defeat a card in the galaxy row, draw a card." }, + { "id": "jabbathehutt", "name": "Jabba the Hutt", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 8, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "draw", "n": 1, "nForce": 2, "cost": { "exileFromHand": 1 } }, + "text": "Exile 1 card from your hand to draw 1 card (2 cards instead if the Force is with you)." }, + { "id": "crocruiser", "name": "C-ROC Cruiser", "faction": "neutral", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 0, "resources": 2, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, + "ability": null, + "text": "A smuggler's idea of a warship." }, + { "id": "khetanna", "name": "Khetanna", "faction": "neutral", "type": "capital", "unique": true, "traits": ["Capital Ship", "Barge"], "cost": 6, "attack": 2, "resources": 2, "force": 0, "hp": 5, "count": 1, "target": null, "reward": null, + "ability": null, + "text": "Jabba's sail barge: pleasure craft, prison, and gallows in one." } + ], + + "bases": { + "rebel": [ + { "id": "dantooine", "name": "Dantooine", "hp": 8, "starting": true, "ability": null, + "text": "Rebel Outpost. Begin the game with this base in play." }, + { "id": "hoth", "name": "Hoth", "hp": 10, "starting": false, + "ability": { "timing": "onReveal", "op": "draw", "n": 2 }, + "text": "Echo Station. When you reveal this base, draw 2 cards." }, + { "id": "sullust", "name": "Sullust", "hp": 12, "starting": false, + "ability": { "timing": "onReveal", "op": "exileCards", "n": 2, "optional": true }, + "text": "Staging Point. When you reveal this base, exile up to 2 cards from your hand or discard pile." }, + { "id": "moncala", "name": "Mon Cala", "hp": 12, "starting": false, + "ability": { "timing": "constant", "op": "capitalDiscount", "n": 1 }, + "text": "Shipyards. Your capital ships cost 1 less resource." }, + { "id": "yavin4", "name": "Yavin IV", "hp": 14, "starting": false, + "ability": { "timing": "constant", "op": "onPlayTraitAttack", "trait": "Fighter", "amount": 1 }, + "text": "Great Temple. When you play a Fighter, it gains 1 attack." } + ], + "empire": [ + { "id": "lothal", "name": "Lothal", "hp": 8, "starting": true, "ability": null, + "text": "Imperial Outpost. Begin the game with this base in play." }, + { "id": "corellia", "name": "Corellia", "hp": 10, "starting": false, + "ability": { "timing": "constant", "op": "capitalDiscount", "n": 1 }, + "text": "Shipyards. Your capital ships cost 1 less resource." }, + { "id": "coruscant", "name": "Coruscant", "hp": 12, "starting": false, + "ability": { "timing": "onReveal", "op": "gainResources", "n": 3 }, + "text": "Imperial Center. When you reveal this base, gain 3 resources." }, + { "id": "deathstar", "name": "Death Star", "hp": 14, "starting": false, + "ability": { "timing": "action", "op": "damageBase", "n": 1 }, + "text": "Battle Station. Once per turn: deal 1 damage to the enemy base." }, + { "id": "endor", "name": "Endor", "hp": 16, "starting": false, "ability": null, + "text": "Shield Generator. The toughest Imperial position in the galaxy." } + ] + } +} diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 568a186..e2b31a4 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -103,3 +103,5 @@ registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGa registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 }); registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 }); +registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 }); +registerGame({ slug: 'swdbg', name: 'Star Wars Deckbuilder', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78 }); diff --git a/src/games/dungeonboss/DungeonBossAI.js b/src/games/dungeonboss/DungeonBossAI.js new file mode 100644 index 0000000..62639f6 --- /dev/null +++ b/src/games/dungeonboss/DungeonBossAI.js @@ -0,0 +1,385 @@ +// Dungeon Boss AI — synchronous heuristics over the engine's public view +// (publicView in DungeonBossLogic redacts rival hands and pending builds; the +// AI never sees hidden information). One handler per decision kind, routed +// through decide(). Skill 1–5 follows the AzulAI shape: widening top-N with +// score noise and a blunder chance at low skill. + +import { + BOSSES, ROOMS, SPELLS, HEROES, CLASSES, + heroSouls, roomDef, spellDef, heroDef, +} from './DungeonBossData.js'; +import { + legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets, +} from './DungeonBossLogic.js'; + +const SKILL_PROFILES = { + 1: { topN: 5, blunder: 0.40, noise: 10, actChance: 0.25, delay: [700, 1200] }, + 2: { topN: 4, blunder: 0.22, noise: 7, actChance: 0.45, delay: [650, 1100] }, + 3: { topN: 3, blunder: 0.10, noise: 4, actChance: 0.70, delay: [600, 1000] }, + 4: { topN: 2, blunder: 0.03, noise: 2, actChance: 0.90, delay: [520, 900] }, + 5: { topN: 1, blunder: 0.00, noise: 0, actChance: 1.00, delay: [440, 820] }, +}; +function profileFor(skill) { + return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; +} +export function nextThinkDelay(skill) { + const [lo, hi] = profileFor(skill).delay; + return lo + Math.random() * (hi - lo); +} + +// rnd: a plain ()=>[0,1) function (seeded in verify, Math.random in the scene). +function pickScored(cands, skill, rnd) { + const prof = profileFor(skill); + if (!cands.length) return null; + if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice; + const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise })); + jittered.sort((a, b) => b.s - a.s); + const n = Math.min(prof.topN, jittered.length); + return jittered[Math.floor(rnd() * n)].choice; +} + +const me = (view, seat) => view.players[seat]; +const rivals = (view, seat) => view.players.filter((p) => p.alive && p.seat !== seat); +const soulLeader = (view, seat) => rivals(view, seat).sort((a, b) => b.souls - a.souls)[0] || null; +const canKill = (view, seat, hero) => dungeonDamage(view, seat) >= hero.hp; + +function roomValue(def) { + const icons = Object.values(def.treasure || {}).reduce((a, b) => a + b, 0); + return def.dmg * 2 + icons * 1.5 + (def.effects || []).length + (def.passive ? 1 : 0); +} +function handCardValue(inst) { + const r = ROOMS[inst.id]; + if (r) return roomValue(r) + (r.advanced ? 1 : 0); + return 5; // spells are versatile — keep them over weak rooms +} + +// ── Decision handlers ──────────────────────────────────────────────────────── + +function chooseSetupDiscard(view, seat) { + const p = me(view, seat); + const all = [...p.hand.rooms, ...p.hand.spells] + .map((c) => ({ uid: c.uid, v: handCardValue(c) + (ROOMS[c.id] && ROOMS[c.id].advanced ? -2 : 0) })) + .sort((a, b) => a.v - b.v); + // Never discard down to zero ordinary rooms — the setup build needs one. + const isOrdinary = (uid) => { + const c = p.hand.rooms.find((x) => x.uid === uid); + return c && !ROOMS[c.id].advanced; + }; + const picked = []; + for (const cand of all) { + if (picked.length === 2) break; + const remainingOrdinary = p.hand.rooms.filter((c) => !ROOMS[c.id].advanced + && !picked.includes(c.uid) && c.uid !== cand.uid).length; + if (isOrdinary(cand.uid) && remainingOrdinary === 0) continue; + picked.push(cand.uid); + } + // Fallback (hand was nearly all ordinary): take the two cheapest regardless. + while (picked.length < 2) { + const next = all.find((c) => !picked.includes(c.uid)); + picked.push(next.uid); + } + return picked; +} + +function scoreBuild(view, seat, b) { + const p = me(view, seat); + const def = roomDef(p.hand.rooms.find((c) => c.uid === b.roomUid)); + let s = 0; + const isNew = b.slotIdx >= p.dungeon.length; + // Value of the room itself, minus what a build-over buries. + s += def.dmg * 1.5 + Object.values(def.treasure || {}).reduce((a, c) => a + c, 0); + if (!isNew) { + const buried = roomDef(p.dungeon[b.slotIdx].room); + s -= roomValue(buried) * 0.6; + if (buried.dmg <= 1 && !buried.passive) s += 2; // upgrading a weak room is fine + } else { + s += 2; + if (p.dungeon.length === 4 && !p.boss.leveledUp) s += 6; // level-up on the 5th room + } + if (def.advanced) s += 2; + // Bait consequences: compare who I attract (and whether I can kill them) + // before and after this build — existing icons already pull heroes, so + // exposure to unkillable heroes matters as much as new pulls. + const curDmg = dungeonDamage(view, seat); + const projDmg = curDmg + def.dmg + (isNew ? 0 : -roomDef(p.dungeon[b.slotIdx].room).dmg); + for (const hero of view.town) { + const h = heroDef(hero); + if (h.fool) continue; + const curMine = treasureCount(view, seat, h.cls); + const mine = curMine + + (def.treasure?.[h.cls] || 0) + - (isNew ? 0 : ((roomDef(p.dungeon[b.slotIdx].room).treasure || {})[h.cls] || 0)); + const bestRival = Math.max(0, ...rivals(view, seat).map((r) => treasureCount(view, r.seat, h.cls))); + const wasMax = curMine > bestRival; + const nowMax = mine > bestRival; + const killCur = curDmg >= hero.hp; + const killNow = projDmg >= hero.hp; + if (!wasMax && nowMax) { + s += killNow ? 4 + heroSouls(h) * 3 : -(6 + (h.epic ? 6 : 0)); + } else if (wasMax && nowMax) { + if (!killCur && killNow) s += 8; // this build saves me a wound + else if (!killNow) s -= 2; // still doomed; prefer damage elsewhere + } else if (wasMax && !nowMax) { + s += killCur ? -4 : 5; // dodged an unkillable hero / lost a soul + } + } + return s; +} + +function chooseBuild(view, seat, skill, rnd, decision) { + const builds = legalBuilds(view, seat); + if (decision.setup) { + let best = null; let bestV = -1e9; + for (const b of builds) { + const def = roomDef(me(view, seat).hand.rooms.find((c) => c.uid === b.roomUid)); + if (def.advanced) continue; // advanced can't open a dungeon anyway + const v = roomValue(def); + if (v > bestV) { bestV = v; best = b; } + } + return best || builds[0] || null; + } + const cands = builds.map((b) => ({ choice: b, s: scoreBuild(view, seat, b) })); + cands.push({ choice: null, s: -1 }); // passing is rarely right + return pickScored(cands, skill, rnd); +} + +// One scored list across castable spells + activatable rooms + pass. +function chooseWindow(view, seat, skill, rnd, decision) { + const prof = profileFor(skill); + const acts = windowActions(view, seat); + if ((!acts.spells.length && !acts.rooms.length) || rnd() > prof.actChance) return { pass: true }; + const p = me(view, seat); + const isAdv = decision.window === 'advStart'; + const myTurn = isAdv && decision.advSeat === seat; + const leader = soulLeader(view, seat); + const cands = [{ choice: { pass: true }, s: 1 }]; + + const survivor = (who) => who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat)); + const dying = (who) => who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat)); + + for (const sp of acts.spells) { + const def = spellDef({ id: sp.id }); + const op = def.op.op; + const push = (target, s) => cands.push({ choice: { spellUid: sp.uid, target }, s }); + if (op === 'dmgBoostRoom' && myTurn && survivor(p)) { + // Boost my biggest matching room if it flips a survivor to a kill. + const own = (sp.targets || []).filter((t) => t.seat === seat); + const best = own.sort((a, b) => roomDef(p.dungeon[b.slotIdx].room).dmg - roomDef(p.dungeon[a.slotIdx].room).dmg)[0]; + if (best && survivor(p).hp <= dungeonDamage(view, seat) + def.op.amount) push(best, 12); + else if (best) push(best, 3); + } else if (op === 'healHero' && isAdv && !myTurn && leader) { + const t = (sp.targets || []).find((t2) => { + const owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid)); + if (!owner || owner.seat === seat) return false; + const hero = owner.entrance.find((h) => h.uid === t2.uid); + return hero.hp <= dungeonDamage(view, owner.seat) && hero.hp + def.op.amount > dungeonDamage(view, owner.seat); + }); + if (t) push(t, 11); + } else if (op === 'damageHero' && myTurn) { + const sv = survivor(p); + if (sv && sv.hp - p.dungeon.length <= dungeonDamage(view, seat)) push({ kind: 'hero', uid: sv.uid }, 11); + } else if (op === 'teleportHero' && myTurn) { + const sv = survivor(p); + if (sv && sv.hp <= dungeonDamage(view, seat) * 2) push({ kind: 'hero', uid: sv.uid }, 10); + } else if (op === 'fearHero' && myTurn) { + const sv = survivor(p); + if (sv) push({ kind: 'hero', uid: sv.uid }, 9); // shoo away a hero I can't kill + } else if (op === 'deactivateRoom' && !isAdv && leader) { + const t = (sp.targets || []).filter((t2) => t2.seat === leader.seat) + .sort((a, b) => roomDef(leader.dungeon[b.slotIdx].room).dmg - roomDef(leader.dungeon[a.slotIdx].room).dmg)[0]; + if (t) push(t, 6); + } else if (op === 'lureHero') { + const t = (sp.targets || []).find((t2) => { + const hero = view.town.find((h) => h.uid === t2.uid); + return hero && canKill(view, seat, hero); + }); + if (t) push(t, 8 + (heroDef(view.town.find((h) => h.uid === t.uid)).epic ? 4 : 0)); + } else if (op === 'resurrectAttack' && leader) { + const t = (sp.targets || []).find((t2) => { + if (t2.seat !== leader.seat) return false; + const hero = leader.soulCards.find((h) => h.uid === t2.uid); + return hero && hero.hpMax + (def.op.hpBonus || 0) > dungeonDamage(view, leader.seat); + }); + if (t) push(t, 9); + } else if (op === 'blockHeroes') { + const t = (sp.targets || [])[0]; + if (t) push(t, 7); + } else if (op === 'caveIn' && myTurn) { + const sv = survivor(p); + if (sv) { + const t = (sp.targets || []).sort((a, b) => + roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; + if (t) push(t, 8); + } + } else if (op === 'sacrificeSoulDraw') { + if (p.hand.spells.length <= 1 && p.souls > 2 && p.souls < 9) push((sp.targets || [])[0], 3); + } else if (op === 'handReset') { + if (p.hand.rooms.length + p.hand.spells.length <= 2) push(null, 4); + } else if (op === 'noBuildRound') { + const rivalNearLevel = rivals(view, seat).some((r) => r.dungeon.length === 4 && !r.boss.leveledUp); + if (rivalNearLevel) push(null, 5); + } else if (op === 'extraBuild') { + if (p.hand.rooms.length) push(null, 6); + } else if (op === 'dmgBoostAllRooms' && myTurn && survivor(p)) { + const t = (sp.targets || [])[0]; + if (t) push(t, 6); + } + } + for (const ra of acts.rooms) { + const def = roomDef(p.dungeon[ra.slotIdx].room); + const eff = (def.effects || [])[ra.effIdx]; + const push = (target, s, extra = {}) => cands.push({ choice: { slotIdx: ra.slotIdx, effIdx: ra.effIdx, target, ...extra }, s }); + if (eff.op === 'pitTrap' && myTurn) { + const sv = survivor(p); + if (sv) push(null, 12); // guaranteed kill beats keeping a 1-damage trap + } else if (eff.op === 'rampTrap' && myTurn) { + const sv = survivor(p); + if (sv && sv.hp <= dungeonDamage(view, seat) + 5) { + const t = (ra.targets || []).sort((a, b) => + roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; + if (t) push(t, 10); + } + } else if (eff.op === 'dmgBoostAllRooms' && myTurn && survivor(p)) { + const t = (ra.targets || []).sort((a, b) => + roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0]; + if (t) push(t, 9); + } else if (eff.op === 'doubleTreasure' && !isAdv) { + const killable = view.town.filter((h) => !heroDef(h).fool && canKill(view, seat, h)); + const bt = baitTargets(view); + if (killable.some((h) => bt[h.uid] !== seat)) push(null, 7); + } else if (eff.op === 'drawSpell' && eff.cost && eff.cost.discardMonsterRoom) { + if (p.hand.rooms.filter((c) => roomDef(c).type === 'monster').length > 2) push(null, 2); + } else if (eff.op === 'recoverDiscard') { + const bestRoom = (ra.targets || []).map((t) => view.roomDiscard.find((c) => c.uid === t.uid)) + .filter(Boolean).sort((a, b) => roomValue(roomDef(b)) - roomValue(roomDef(a)))[0]; + if (bestRoom && roomValue(roomDef(bestRoom)) >= 8 && p.hand.rooms.length >= (eff.cost?.discardRooms || 0) + 1) { + push({ kind: 'card', seat: -1, uid: bestRoom.uid }, 3); + } + } else if (eff.op === 'opponentDiscardRandom' && leader) { + const t = (ra.targets || []).find((t2) => t2.seat === leader.seat); + if (t && p.souls >= 6) push(t, 2); + } else if (eff.op === 'resurrectAttack' && leader) { + const t = (ra.targets || []).find((t2) => { + if (t2.seat !== leader.seat) return false; + const hero = leader.soulCards.find((h) => h.uid === t2.uid); + return hero && hero.hpMax > dungeonDamage(view, leader.seat); + }); + if (t) push(t, 8); + } + } + return pickScored(cands, skill, rnd) || { pass: true }; +} + +function chooseReact(view, seat, skill, rnd, decision) { + const prof = profileFor(skill); + if (rnd() > prof.actChance) return null; + const t = decision.target; + const aimedAtMe = t && (t.seat === seat + || (t.kind === 'hero' && me(view, seat).entrance.some((h) => h.uid === t.uid))); + const casterIsLeader = soulLeader(view, seat) && decision.casterSeat === soulLeader(view, seat).seat; + if (!aimedAtMe && !(casterIsLeader && rnd() < 0.3)) return null; + const p = me(view, seat); + // Prefer the All-Seeing Eye (costs a spare spell) over burning Counterspell. + for (let slotIdx = 0; slotIdx < p.dungeon.length; slotIdx++) { + const slot = p.dungeon[slotIdx]; + if (slot.deactivated) continue; + const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction'); + if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length > 1) { + const spare = p.hand.spells.find((c) => spellDef(c).op.op !== 'counterSpell') || p.hand.spells[0]; + return { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid }; + } + } + const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell'); + if (cs && aimedAtMe) return { type: 'counterspell', spellUid: cs.uid }; + return null; +} + +function chooseTarget(view, seat, skill, rnd, decision) { + const cands = decision.candidates || []; + const p = me(view, seat); + const best = (scoreFn) => { + let top = null; let topS = -1e9; + for (const c of cands) { const s = scoreFn(c); if (s > topS) { topS = s; top = c; } } + return { top, topS }; + }; + switch (decision.op) { + case 'destroyOwnRoom': // forced (Robobo) — lose the least + return best((c) => -roomValue(roomDef(p.dungeon[c.slotIdx].room))).top; + case 'recoverDiscard': { + const { top } = best((c) => { + const r = view.roomDiscard.find((x) => x.uid === c.uid); + if (r) return roomValue(roomDef(r)); + return 4; // an unknown spell is a decent pull + }); + return top; + } + case 'revealTake': { + // Level-up peek: real hands are exposed by the candidate list via uids the + // engine validated; without card identity here, take from the soul leader. + const leader = soulLeader(view, seat); + return best((c) => (leader && c.seat === leader.seat ? 2 : 1) + rnd()).top; + } + case 'stealRandom': case 'opponentDiscardRandom': { + const leader = soulLeader(view, seat); + return best((c) => (leader && c.seat === leader.seat ? 2 : 1)).top; + } + case 'tutorAdvanced': + return best((c) => { + const inst = [...(view.roomDiscard || [])].find((x) => x.uid === c.roomUid); + return inst ? roomValue(roomDef(inst)) : 5 + rnd(); + }).top; + case 'killHeroTown': + return best((c) => { + const hero = view.town.find((h) => h.uid === c.uid); + return hero ? heroSouls(heroDef(hero)) * 4 + hero.hp : 0; + }).top; + case 'lureHero': { + const { top, topS } = best((c) => { + const hero = view.town.find((h) => h.uid === c.uid) + || [(view.deckCounts ? null : null)].filter(Boolean)[0]; + if (!hero) return -1; // deck heroes are unknown to the view; skip them + const kill = canKill(view, seat, hero); + return (kill ? 8 : -8) + heroSouls(heroDef(hero)) * 2; + }); + if (decision.optional && topS <= 0) return null; + return top; + } + case 'swapRooms': + return decision.optional ? null : cands[0]; + default: { + const pick = cands[Math.floor(rnd() * cands.length)]; + return decision.optional && rnd() < 0.5 ? pick : (decision.optional ? pick : pick); + } + } +} + +function chooseDiscard(view, seat, decision) { + const p = me(view, seat); + const pool = decision.cardType === 'spell' ? p.hand.spells : p.hand.rooms; + return pool.slice().sort((a, b) => handCardValue(a) - handCardValue(b)) + .slice(0, decision.n).map((c) => c.uid); +} + +function chooseRoomDraw(view, seat) { + return me(view, seat).hand.spells.length < 2 ? 'spell' : 'room'; +} + +// ── Router ────────────────────────────────────────────────────────────────── +// Returns the argument for the matching act* mutator: +// setupDiscard → [uidA, uidB] · build → {roomUid,slotIdx}|null · +// window → {spellUid,target}|{slotIdx,effIdx,target,costUids}|{pass:true} · +// react → response|null · target → targetRef|null · discard → [uids] · +// roomDraw → 'room'|'spell' +export function decide(view, decision, skill, rnd = Math.random) { + const seat = decision.seat; + switch (decision.kind) { + case 'setupDiscard': return chooseSetupDiscard(view, seat); + case 'build': return chooseBuild(view, seat, skill, rnd, decision); + case 'window': return chooseWindow(view, seat, skill, rnd, decision); + case 'react': return chooseReact(view, seat, skill, rnd, decision); + case 'target': return chooseTarget(view, seat, skill, rnd, decision); + case 'discard': return chooseDiscard(view, seat, decision); + case 'roomDraw': return chooseRoomDraw(view, seat); + default: return null; + } +} diff --git a/src/games/dungeonboss/DungeonBossData.js b/src/games/dungeonboss/DungeonBossData.js new file mode 100644 index 0000000..35bffc9 --- /dev/null +++ b/src/games/dungeonboss/DungeonBossData.js @@ -0,0 +1,444 @@ +// Dungeon Boss — card content (Boss Monster base set: real names/stats, rules text our own wording). +// Effects are machine-encoded; `text` is display-only. The engine consumes the encodings, so the +// two must stay in sync — verifyDungeonBoss.js cross-checks ops against the supported enum. + +export const CLASSES = ['fighter', 'mage', 'thief', 'cleric']; + +export const CLASS_INFO = { + fighter: { label: 'Fighter', color: 0xc0392b, glyph: 'sword' }, + mage: { label: 'Mage', color: 0x2e6fd8, glyph: 'tome' }, + thief: { label: 'Thief', color: 0xd9a520, glyph: 'coins' }, + cleric: { label: 'Cleric', color: 0x2ea08c, glyph: 'ankh' }, +}; + +// --------------------------------------------------------------------------- +// Bosses — one-time Level Up ability when the 5th room is built. +// Turn order: highest XP first. End-of-game soul tie: LOWEST XP wins. +// --------------------------------------------------------------------------- +export const BOSSES = { + draculord: { + name: 'Draculord', xp: 900, treasure: 'cleric', + levelUp: { op: 'revealTake' }, + text: 'Level Up: Look at an opponent’s hand and take a card of your choice.', + }, + cleopatra: { + name: 'Cleopatra', xp: 850, treasure: 'thief', + levelUp: { op: 'tutorAdvanced', roomType: 'trap' }, + text: 'Level Up: Fetch an Advanced Trap Room from the deck or discard and build it at once.', + }, + kingcroak: { + name: 'King Croak', xp: 800, treasure: 'fighter', + levelUp: { op: 'tutorAdvanced', roomType: 'monster' }, + text: 'Level Up: Fetch an Advanced Monster Room from the deck or discard and build it at once.', + }, + xyzax: { + name: 'Xyzax', xp: 750, treasure: 'cleric', + levelUp: { op: 'recoverDiscard', pool: 'any', n: 2 }, + text: 'Level Up: Pick two cards from the discard piles and add them to your hand.', + }, + cerebellus: { + name: 'Cerebellus', xp: 650, treasure: 'mage', + levelUp: { op: 'drawThenDiscard', draw: 'spell', n: 3, discard: 'spell', d: 1 }, + text: 'Level Up: Draw three Spells, then discard one Spell.', + }, + seducia: { + name: 'Seducia', xp: 600, treasure: 'mage', + levelUp: { op: 'lureHero', from: 'townOrDeck' }, + text: 'Level Up: Pick any Hero in town or in the Hero decks and drop it at your entrance.', + }, + gorgona: { + name: 'Gorgona', xp: 500, treasure: 'thief', + levelUp: { op: 'killHeroTown' }, + text: 'Level Up: Turn one Hero in town to stone — score its soul immediately.', + }, + robobo: { + name: 'Robobo', xp: 400, treasure: 'fighter', + levelUp: { op: 'wreckEachOpponent' }, + text: 'Level Up: Every opponent must demolish one room of their own dungeon.', + }, +}; + +// --------------------------------------------------------------------------- +// Rooms. type: 'monster'|'trap'; advanced rooms may only be built over a room +// sharing at least one treasure class. treasure: { class: count }. +// Effect triggers: +// onBuild — resolves when the room is revealed in the build flip +// onHeroDieHere — once per round, when a hero dies in this room +// turnStart — choice offered at the start of each round +// activated — player action during a spell window (build/adventure); may have a cost +// reaction — offered when an opponent casts a spell (cancel) +// passive — computed directly by the engine (keyword field, no op) +// --------------------------------------------------------------------------- +export const ROOMS = { + // --- Cleric --- + darkaltar: { + name: 'Dark Altar', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 2 }, count: 3, + effects: [{ trigger: 'activated', window: 'any', cost: { destroySelf: true }, op: 'recoverDiscard', pool: 'any', n: 1 }], + text: 'Sacrifice this room: reclaim any one card from a discard pile.', + }, + opengrave: { + name: 'Open Grave', type: 'trap', advanced: false, dmg: 2, treasure: { cleric: 1 }, count: 2, + effects: [{ trigger: 'onHeroDieHere', op: 'recoverDiscard', pool: 'room', n: 1, auto: true }], + text: 'When a hero dies here (once a round), reclaim a Room card from the discard pile.', + }, + specterssanctum: { + name: "Specter's Sanctum", type: 'monster', advanced: false, dmg: 2, treasure: { cleric: 1 }, count: 3, + effects: [{ trigger: 'onBuild', op: 'opponentDiscardRandom', filter: 'spell' }], + text: 'On build: a rival of your choice loses a random Spell.', + }, + succubusspa: { + name: 'Succubus Spa', type: 'monster', advanced: false, dmg: 1, treasure: { cleric: 1 }, count: 3, + effects: [{ trigger: 'onHeroDieHere', op: 'stealRandom', filter: 'any' }], + text: 'When a hero dies here (once a round), snatch a random card from a rival’s hand.', + }, + dracolichlair: { + name: 'Dracolich Lair', type: 'monster', advanced: true, dmg: 3, treasure: { cleric: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'any', oncePerTurn: true, cost: { discardRooms: 2 }, op: 'recoverDiscard', pool: 'room', n: 1 }], + text: 'Once a round, toss two Room cards to reclaim any Room card from the discard pile.', + }, + vampirebordello: { + name: 'Vampire Bordello', type: 'monster', advanced: true, dmg: 3, treasure: { cleric: 1 }, count: 2, + effects: [{ trigger: 'onHeroDieHere', op: 'healWound' }], + text: 'When a hero dies here (once a round), mend one of your wounds.', + }, + // --- Fighter --- + goblinarmory: { + name: 'Goblin Armory', type: 'monster', advanced: false, dmg: 1, treasure: { fighter: 2 }, count: 3, + passive: 'adjacentMonsterBoost', + text: 'Monster Rooms next to this one strike for +1 damage.', + }, + golemfactory: { + name: 'Golem Factory', type: 'monster', advanced: false, dmg: 2, treasure: { fighter: 1 }, count: 3, + effects: [{ trigger: 'onHeroDieHere', op: 'drawRoom', n: 1 }], + text: 'When a hero dies here (once a round), draw a Room card.', + }, + minotaursmaze: { + name: "Minotaur's Maze", type: 'monster', advanced: false, dmg: 0, treasure: { fighter: 1 }, count: 2, + passive: 'minotaurBounce', + text: 'The first hero through each round is shoved back into the room it just left.', + }, + neanderthalcave: { + name: 'Neanderthal Cave', type: 'monster', advanced: false, dmg: 3, treasure: { fighter: 1 }, count: 3, + passive: 'noAdvancedOnTop', + text: 'No Advanced Room may ever be built over this cave.', + }, + beastmenagerie: { + name: 'Beast Menagerie', type: 'monster', advanced: true, dmg: 4, treasure: { fighter: 1 }, count: 2, + effects: [{ trigger: 'onBuildOtherMonster', op: 'drawRoom', n: 1, oncePerTurn: true }], + text: 'Once a round, when you build another Monster Room, draw a Room card.', + }, + monstersballroom: { + name: "Monster's Ballroom", type: 'monster', advanced: true, dmg: 0, treasure: { fighter: 1 }, count: 2, + passive: 'ballroomDamage', + text: 'Deals damage equal to the number of Monster Rooms in your dungeon.', + }, + // --- Mage --- + brainsuckerhive: { + name: 'Brainsucker Hive', type: 'monster', advanced: false, dmg: 2, treasure: { mage: 1 }, count: 3, + effects: [{ trigger: 'onHeroDieHere', op: 'drawSpell', n: 1 }], + text: 'When a hero dies here (once a round), draw a Spell card.', + }, + darklaboratory: { + name: 'Dark Laboratory', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 2 }, count: 3, + effects: [{ trigger: 'onBuild', op: 'drawThenDiscard', draw: 'spell', n: 2, discard: 'spell', d: 1 }], + text: 'On build: draw two Spells, then discard one Spell.', + }, + hauntedlibrary: { + name: 'Haunted Library', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 1 }, count: 2, + passive: 'librarySpellDraw', + text: 'At the start of each round you may draw a Spell instead of a Room.', + }, + witchskitchen: { + name: "Witch's Kitchen", type: 'monster', advanced: false, dmg: 1, treasure: { mage: 1 }, count: 3, + effects: [{ trigger: 'activated', window: 'any', oncePerTurn: true, cost: { discardMonsterRoom: true }, op: 'drawSpell', n: 1 }], + text: 'Once a round, toss a Monster Room card to draw a Spell card.', + }, + allseeingeye: { + name: 'All-Seeing Eye', type: 'trap', advanced: true, dmg: 3, treasure: { mage: 1 }, count: 2, + effects: [{ trigger: 'reaction', oncePerTurn: true, cost: { discardSpell: true }, op: 'counterSpell' }], + text: 'Once a round, toss a Spell card to cancel a rival’s spell as it is cast.', + }, + ligersden: { + name: "Liger's Den", type: 'monster', advanced: true, dmg: 2, treasure: { mage: 1 }, count: 2, + effects: [{ trigger: 'onCastSpell', op: 'drawSpell', n: 1, oncePerTurn: true }], + text: 'Once a round, when you cast a Spell, draw a Spell card.', + }, + // --- Thief --- + bottomlesspit: { + name: 'Bottomless Pit', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 3, + effects: [{ trigger: 'activated', window: 'adventure', cost: { destroySelf: true }, op: 'pitTrap' }], + text: 'Sacrifice this room: the next hero to enter it this round plummets and dies.', + }, + boulderramp: { + name: 'Boulder Ramp', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'adventure', cost: { destroyOther: true }, op: 'rampTrap' }], + text: 'Demolish another of your rooms: the next hero entering here takes 5 extra damage.', + }, + dizzygashallway: { + name: 'Dizzygas Hallway', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 3, + passive: 'dizzygas', + text: 'If the room after this one is a Trap Room, that room strikes for +2 damage.', + }, + jackpotstash: { + name: 'Jackpot Stash', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 2 }, count: 3, + effects: [{ trigger: 'activated', window: 'build', cost: { destroySelf: true }, op: 'doubleTreasure' }], + text: 'Sacrifice this room: your rooms’ treasure counts double until end of round.', + }, + recyclingcenter: { + name: 'Recycling Center', type: 'trap', advanced: true, dmg: 3, treasure: { thief: 1 }, count: 2, + effects: [{ trigger: 'onOwnRoomDestroyed', op: 'drawRoom', n: 2 }], + text: 'Whenever another of your rooms is destroyed, draw two Room cards.', + }, + thecrushinator: { + name: 'The Crushinator', type: 'trap', advanced: true, dmg: 2, treasure: { thief: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'adventure', oncePerTurn: true, cost: { destroyOther: true }, op: 'dmgBoostAllRooms', amount: 2 }], + text: 'Once a round, demolish another of your rooms: all your rooms strike for +2 until end of round.', + }, + // --- Dual / hybrid treasure --- + centipedetunnel: { + name: 'Centipede Tunnel', type: 'monster', advanced: false, dmg: 1, treasure: { fighter: 1, mage: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'swapRooms', optional: true }], + text: 'On build: you may swap the position of two rooms in any one dungeon.', + }, + constructionzone: { + name: 'Construction Zone', type: 'trap', advanced: false, dmg: 1, treasure: { fighter: 1, thief: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'extraBuild', optional: true }], + text: 'On build: you may immediately build one more room.', + }, + dragonhatchery: { + name: 'Dragon Hatchery', type: 'monster', advanced: false, dmg: 0, treasure: { fighter: 1, mage: 1, thief: 1, cleric: 1 }, count: 3, + text: 'Glitters with every kind of treasure — and guards none of it with teeth.', + }, + mimicvault: { + name: 'Mimic Vault', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 1, thief: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'lureHero', from: 'town', filter: 'ordinary', optional: true }], + text: 'On build: drag one ordinary Hero from town to your entrance.', + }, + monstrousmonument: { + name: 'Monstrous Monument', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 1, fighter: 1 }, count: 2, + effects: [{ trigger: 'onBuild', op: 'recoverDiscard', pool: 'monsterRoom', n: 1 }], + text: 'On build: reclaim a Monster Room card from the discard pile.', + }, + torturechamber: { + name: 'Torture Chamber', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 1, thief: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'any', cost: { destroySelf: true }, op: 'opponentDiscardRandom', filter: 'room' }], + text: 'Sacrifice this room: a rival of your choice loses a random Room card.', + }, + zombieprison: { + name: 'Zombie Prison', type: 'monster', advanced: false, dmg: 1, treasure: { cleric: 1, mage: 1 }, count: 2, + effects: [{ trigger: 'activated', window: 'build', cost: { destroySelf: true }, op: 'resurrectAttack', hpBonus: 0 }], + text: 'Sacrifice this room: a dead hero in a rival’s score pile shambles back to their entrance.', + }, +}; + +// --------------------------------------------------------------------------- +// Spells. phase: 'build' | 'adventure' | 'both'. +// --------------------------------------------------------------------------- +export const SPELLS = { + annihilator: { + name: 'Annihilator', phase: 'adventure', count: 2, + op: { op: 'dmgBoostRoom', amount: 3, filter: 'trap' }, + text: 'One Trap Room strikes for +3 damage until end of round.', + }, + assassin: { + name: 'Assassin', phase: 'adventure', count: 3, + op: { op: 'healHero', amount: 3, where: 'opponentDungeon' }, + text: 'A hero in a rival’s dungeon gains +3 health until end of round.', + }, + cavein: { + name: 'Cave-In', phase: 'adventure', count: 2, + op: { op: 'caveIn' }, + text: 'Collapse one of your own rooms — any hero inside it is crushed.', + }, + counterspell: { + name: 'Counterspell', phase: 'both', count: 2, + op: { op: 'counterSpell' }, + text: 'Cancel a spell the moment a rival casts it.', + }, + exhaustion: { + name: 'Exhaustion', phase: 'adventure', count: 1, + op: { op: 'damageHero', amount: 'dungeonSize' }, + text: 'A hero in your dungeon takes damage equal to your room count.', + }, + fear: { + name: 'Fear', phase: 'adventure', count: 2, + op: { op: 'fearHero' }, + text: 'A hero in any dungeon flees back to town.', + }, + freeze: { + name: 'Freeze', phase: 'both', count: 4, + op: { op: 'deactivateRoom' }, + text: 'Ice over one room in any dungeon — no damage, treasure, or abilities until end of round.', + }, + giantsize: { + name: 'Giant Size', phase: 'adventure', count: 2, + op: { op: 'dmgBoostRoom', amount: 3, filter: 'monster' }, + text: 'One Monster Room strikes for +3 damage until end of round.', + }, + jeopardy: { + name: 'Jeopardy', phase: 'both', count: 2, + op: { op: 'handReset' }, + text: 'Every player discards their hand, then draws one Spell and two Rooms.', + }, + koboldstrike: { + name: 'Kobold Strike', phase: 'build', count: 1, + op: { op: 'noBuildRound' }, + text: 'The kobolds walk out: nobody may build a room this round.', + }, + motivation: { + name: 'Motivation', phase: 'build', count: 2, + op: { op: 'extraBuild', cond: 'fewerRooms' }, + text: 'If a rival has more rooms than you, you may build a second room this round.', + }, + princessinperil: { + name: 'Princess in Peril', phase: 'build', count: 2, + op: { op: 'lureHero', from: 'town' }, + text: 'Stage a kidnapping: one Hero in town rushes straight to your entrance.', + }, + soulharvest: { + name: 'Soul Harvest', phase: 'both', count: 1, + op: { op: 'sacrificeSoulDraw', n: 2 }, + text: 'Burn one scored soul to draw two Spell cards.', + }, + teleportation: { + name: 'Teleportation', phase: 'adventure', count: 2, + op: { op: 'teleportHero' }, + text: 'Yank a hero in your dungeon back to your entrance — it walks the gauntlet again.', + }, + trepidation: { + name: 'Trepidation', phase: 'adventure', count: 1, + op: { op: 'blockHeroes', cond: 'twoMoreSouls' }, + text: 'Pick a player at least two souls ahead of you: no hero enters their dungeon this round.', + }, + zombieattack: { + name: 'Zombie Attack', phase: 'build', count: 2, + op: { op: 'resurrectAttack', hpBonus: 2 }, + text: 'A dead hero in a rival’s score pile rises with +2 health and storms their entrance.', + }, +}; + +// --------------------------------------------------------------------------- +// Heroes. Ordinary: 1 soul / 1 wound. Epic: 2 souls / 2 wounds. +// The Fool is lured to the dungeon of the player with the FEWEST souls. +// Hero deck sizing per player count (ordinary/epic per class) handled in buildDecks. +// --------------------------------------------------------------------------- +export const HEROES = { + // Ordinary — Cleric + nick: { name: 'Nick the Masher', cls: 'cleric', hp: 4, epic: false }, + pugi: { name: 'Pugi the Druidess', cls: 'cleric', hp: 4, epic: false }, + acacia: { name: 'Acacia, Warrior of Light', cls: 'cleric', hp: 6, epic: false }, + charles: { name: 'Charles the Young', cls: 'cleric', hp: 6, epic: false }, + delatorious: { name: 'Delatorious', cls: 'cleric', hp: 8, epic: false }, + romero: { name: 'Romero, the Indigo Friar', cls: 'cleric', hp: 8, epic: false }, + // Ordinary — Fighter + boden: { name: 'Boden the Pantless', cls: 'fighter', hp: 4, epic: false }, + jarek: { name: 'Jarek the Squire', cls: 'fighter', hp: 4, epic: false }, + firesbreath: { name: "Fire's Breath", cls: 'fighter', hp: 6, epic: false }, + samuraitom: { name: 'Samurai Tom', cls: 'fighter', hp: 6, epic: false }, + johnny: { name: 'Johnny of the Evening Watch', cls: 'fighter', hp: 8, epic: false }, + crystolalan: { name: "Crystol & A'lan of Gerd", cls: 'fighter', hp: 8, epic: false }, + // Ordinary — Mage + tieg: { name: 'Tieg and the Magic Bubble', cls: 'mage', hp: 4, epic: false }, + brandork: { name: 'Brandork the Neverwrong', cls: 'mage', hp: 4, epic: false }, + mitchell: { name: 'Mitchell, the Judge', cls: 'mage', hp: 6, epic: false }, + kalish: { name: 'Kalish Ninefingers', cls: 'mage', hp: 6, epic: false }, + dartteon: { name: 'Dartteon, Elf Pyromancer', cls: 'mage', hp: 8, epic: false }, + koey: { name: 'Koey, the Last Dragon Mage', cls: 'mage', hp: 8, epic: false }, + // Ordinary — Thief + joman: { name: 'Joman Chimm, Cutpurse', cls: 'thief', hp: 4, epic: false }, + lance: { name: 'Lance Uppercut', cls: 'thief', hp: 4, epic: false }, + jesta: { name: 'Jesta the Rogue', cls: 'thief', hp: 6, epic: false }, + kins: { name: 'Kins Klauski', cls: 'thief', hp: 6, epic: false }, + digby: { name: 'Sir Digby Apple', cls: 'thief', hp: 8, epic: false }, + jerome: { name: 'Jerome, Kung Fu Monkey', cls: 'thief', hp: 8, epic: false }, + // The Fool — any player count; baits to the FEWEST souls + fool: { name: 'The Fool', cls: 'fool', hp: 2, epic: false, fool: true }, + // Epic — Cleric + katelyn: { name: 'Katelyn, Angelic Healer', cls: 'cleric', hp: 11, epic: true }, + kerberos: { name: 'Kerberos Dirtbeard', cls: 'cleric', hp: 11, epic: true }, + vanette: { name: 'Lord Van Ette', cls: 'cleric', hp: 13, epic: true }, + jejune: { name: 'Jejune & Everlea', cls: 'cleric', hp: 13, epic: true }, + // Epic — Fighter + frankov: { name: 'Frankov, the Envoy', cls: 'fighter', hp: 11, epic: true }, + nate: { name: 'Nate the Squidslayer', cls: 'fighter', hp: 11, epic: true }, + antonius: { name: 'Antonius, the Rune Knight', cls: 'fighter', hp: 13, epic: true }, + asmor: { name: 'Asmor the Aweless', cls: 'fighter', hp: 13, epic: true }, + // Epic — Mage + tempros: { name: 'Tempros the Time Marauder', cls: 'mage', hp: 11, epic: true }, + chiakang: { name: 'Chia Kang, Warlock of Yu', cls: 'mage', hp: 11, epic: true }, + terric: { name: 'Terric Warhelm', cls: 'mage', hp: 13, epic: true }, + wayward: { name: 'Wayward, the Drifter', cls: 'mage', hp: 13, epic: true }, + // Epic — Thief + hya: { name: 'Hya, Legendary Shinobi', cls: 'thief', hp: 11, epic: true }, + blackbeard: { name: 'Blackbeard Jake', cls: 'thief', hp: 11, epic: true }, + cecil: { name: 'Cecil Leoran, Factotum', cls: 'thief', hp: 13, epic: true }, + wallbanger: { name: 'Wallbanger Basketweaver', cls: 'thief', hp: 13, epic: true }, +}; + +export function heroSouls(def) { return def.epic ? 2 : 1; } +export function heroWounds(def) { return def.epic ? 2 : 1; } + +// Ordinary/epic heroes used per class, by player count (base-game rule). +export const HERO_DECK_SIZING = { + 2: { ordinaryPerClass: 3, epicPerClass: 2 }, + 3: { ordinaryPerClass: 4, epicPerClass: 3 }, + 4: { ordinaryPerClass: 6, epicPerClass: 4 }, +}; + +// The full op enum — verify script checks every encoded effect against this. +export const OPS = [ + 'drawRoom', 'drawSpell', 'drawThenDiscard', 'recoverDiscard', 'revealTake', 'stealRandom', + 'opponentDiscardRandom', 'handReset', 'wreckEachOpponent', 'swapRooms', 'extraBuild', + 'noBuildRound', 'deactivateRoom', 'tutorAdvanced', 'dmgBoostRoom', 'dmgBoostAllRooms', + 'doubleTreasure', 'damageHero', 'healHero', 'killHeroTown', 'lureHero', 'fearHero', + 'teleportHero', 'resurrectAttack', 'blockHeroes', 'sacrificeSoulDraw', 'healWound', + 'counterSpell', 'pitTrap', 'rampTrap', 'caveIn', +]; + +export const PASSIVES = [ + 'adjacentMonsterBoost', 'minotaurBounce', 'noAdvancedOnTop', 'ballroomDamage', + 'librarySpellDraw', 'dizzygas', +]; + +let uidCounter = 0; +export function makeInstance(id) { return { uid: ++uidCounter, id }; } +export function resetUids() { uidCounter = 0; } + +// Build the four decks for a game. rng: makeRng instance from DungeonBossLogic. +export function buildDecks(rng, nPlayers) { + const rooms = []; + for (const [id, def] of Object.entries(ROOMS)) { + for (let i = 0; i < def.count; i++) rooms.push(makeInstance(id)); + } + const spells = []; + for (const [id, def] of Object.entries(SPELLS)) { + for (let i = 0; i < def.count; i++) spells.push(makeInstance(id)); + } + const sizing = HERO_DECK_SIZING[nPlayers] || HERO_DECK_SIZING[4]; + const heroes = []; + const epics = []; + for (const cls of CLASSES) { + const ord = rng.shuffle(Object.keys(HEROES).filter((id) => !HEROES[id].epic && HEROES[id].cls === cls)); + const epi = rng.shuffle(Object.keys(HEROES).filter((id) => HEROES[id].epic && HEROES[id].cls === cls)); + for (const id of ord.slice(0, sizing.ordinaryPerClass)) heroes.push(instHero(id)); + for (const id of epi.slice(0, sizing.epicPerClass)) epics.push(instHero(id)); + } + heroes.push(instHero('fool')); + return { + rooms: rng.shuffle(rooms), + spells: rng.shuffle(spells), + heroes: rng.shuffle(heroes), + epics: rng.shuffle(epics), + }; +} + +function instHero(id) { + const inst = makeInstance(id); + inst.hp = HEROES[id].hp; + inst.hpMax = HEROES[id].hp; + return inst; +} + +export function roomDef(inst) { return ROOMS[inst.id]; } +export function spellDef(inst) { return SPELLS[inst.id]; } +export function heroDef(inst) { return HEROES[inst.id]; } diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js new file mode 100644 index 0000000..8e6e36f --- /dev/null +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -0,0 +1,1390 @@ +// DungeonBossGame.js — Phaser scene for Dungeon Boss (presentation only; all +// rules live in DungeonBossLogic). The scene pumps the engine's decision queue: +// human decisions enable click UI, AI decisions run on a think delay, and each +// action's event stream drives a sequential animation queue before re-render. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { api } from '../../services/api.js'; +import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; +import { + BOSSES, ROOMS, SPELLS, HEROES, CLASS_INFO, + heroSouls, roomDef, spellDef, heroDef, +} from './DungeonBossData.js'; +import { + newGame, pendingDecision, takeEvents, publicView, + actSetupDiscard, actBuild, actWindow, actReact, actChooseTarget, actDiscard, actRoomDraw, + legalBuilds, windowActions, treasureCount, dungeonDamage, + SOULS_TO_WIN, WOUNDS_TO_DIE, MAX_ROOMS, +} from './DungeonBossLogic.js'; +import { decide, nextThinkDelay } from './DungeonBossAI.js'; + +// ── palette: retro dungeon (dark stone, parchment, class colors) ───────────── +const C = { + bg: 0x120e16, bgTop: 0x221a2a, + parchment: 0xe8dcbf, ink: '#2a2118', inkLight: '#f2ead8', muted: '#9e9080', + gold: 0xd9a520, goldHex: '#d9a520', + monsterEdge: 0x8a5a2a, monsterPlate: 0x7a1f1f, + trapEdge: 0xc9962a, trapPlate: 0x8a6a14, + spellEdge: 0x8f6fd8, spellPlate: 0x453569, + bossEdge: 0x8c1f28, + heroWindow: 0x274060, artWindow: 0x1b2436, + soul: 0xf5d76e, wound: 0xc0392b, frozen: 0x9fd8f0, + // Card face background — slightly off-white; the name plates, art windows, + // and rules boxes stay dark/parchment (own contrast), so only the few bits + // of text drawn directly on the bare face need darker ink variants below. + cardBg: 0xf3eee0, + goldDarkHex: '#8a6a14', spellPhaseDarkHex: '#5b3f96', xpDarkHex: '#57506a', +}; +const DEPTH = { board: 10, town: 20, hand: 40, fx: 60, ui: 80, overlay: 90 }; + +export default class DungeonBossGame extends Phaser.Scene { + constructor() { super('DungeonBossGame'); } + + init(data) { + this._initData = data; + this.gameDef = data?.game ?? { slug: 'dungeonboss', name: 'Dungeon Boss' }; + this.opponents = data?.opponents ?? []; + this.playfield = data?.playfield ?? null; + this.nPlayers = 1 + this.opponents.length; + this.humanSeat = 0; + this.gs = null; + this.mode = { type: 'idle' }; // current human input mode + this.busy = false; // animation lock + this._heroTokens = new Map(); // uid → world position of last render + this._slotRects = new Map(); // `${seat}:${idx}` → {x,y,w,h} + this._handSprites = new Map(); // uid → container + this._soulsPos = new Map(); // seat → {x,y} + this._recorded = false; + } + + create() { + try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ } + this.art = this.cache.json.get('dungeonboss-artwork') || {}; + + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(0); + } else { + const bg = this.add.graphics().setDepth(0); + bg.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1); + bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + // faint stonework + bg.lineStyle(1, 0xffffff, 0.025); + for (let y = 0; y < GAME_HEIGHT; y += 64) { bg.lineBetween(0, y, GAME_WIDTH, y); } + for (let x = 0; x < GAME_WIDTH; x += 96) { bg.lineBetween(x, 0, x, GAME_HEIGHT); } + } + + this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board); + this.townLayer = this.add.container(0, 0).setDepth(DEPTH.town); + this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand); + this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); + this.uiLayer = this.add.container(0, 0).setDepth(DEPTH.ui); + + this.buildStaticUi(); + this.gs = newGame(this.nPlayers, (Math.random() * 1e9) | 0); + takeEvents(this.gs); // swallow gameStart + this.renderAll(); + this.showBanner('Discard 2 cards, then build your first room'); + this.pump(); + } + + // ── static chrome ────────────────────────────────────────────────────────── + buildStaticUi() { + new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), + { width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui); + this.deckText = this.add.text(20, 280, '', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#9e9080', lineSpacing: 6, + }).setDepth(DEPTH.ui); + this.promptText = this.add.text(GAME_WIDTH / 2, 262, '', { + fontFamily: 'Righteous', fontSize: '24px', color: C.goldHex, + backgroundColor: '#120e16dd', padding: { x: 16, y: 6 }, + }).setOrigin(0.5).setDepth(DEPTH.ui); + + // portraits (static; boards re-render around them) + const panels = this.oppPanelCenters(); + this.opponents.forEach((opp, i) => { + createOpponentPortrait(this, opp, panels[i].x - 245, panels[i].y - 8, 36, DEPTH.ui); + }); + createPlayerPortrait(this, 80, 700, 40, DEPTH.ui, 'DungeonBossGame'); + } + + oppPanelCenters() { + const y = 138; + const xs = this.opponents.length === 1 ? [960] + : this.opponents.length === 2 ? [630, 1290] : [400, 960, 1520]; + return xs.map((x) => ({ x, y })); + } + + // ── art lookup ───────────────────────────────────────────────────────────── + artFor(kind, id) { + const sheet = this.art[`${kind}Sheet`]; + const frame = this.art[kind === 'boss' ? 'bosses' : `${kind}s`]?.[id]; + if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) { + return { key: sheet.key, frame }; + } + return null; + } + + // ── vector treasure icons ────────────────────────────────────────────────── + drawTreasureIcon(cont, x, y, cls, s = 10) { + const info = CLASS_INFO[cls]; + if (!info) return; + const g = this.add.graphics(); + g.fillStyle(0x000000, 0.35); + g.fillRoundedRect(x - s - 2, y - s - 2, s * 2 + 4, s * 2 + 4, 3); + g.fillStyle(info.color, 1); + g.lineStyle(Math.max(1.5, s / 5), info.color, 1); + switch (info.glyph) { + case 'sword': + g.fillTriangle(x, y - s, x - s * 0.28, y + s * 0.35, x + s * 0.28, y + s * 0.35); + g.fillRect(x - s * 0.55, y + s * 0.35, s * 1.1, s * 0.18); + g.fillRect(x - s * 0.12, y + s * 0.5, s * 0.24, s * 0.5); + break; + case 'tome': + g.fillRoundedRect(x - s * 0.75, y - s * 0.65, s * 1.5, s * 1.3, 2); + g.lineStyle(Math.max(1, s / 7), 0x120e16, 1); + g.lineBetween(x, y - s * 0.6, x, y + s * 0.6); + g.lineBetween(x - s * 0.5, y - s * 0.2, x - s * 0.15, y - s * 0.2); + g.lineBetween(x + s * 0.15, y - s * 0.2, x + s * 0.5, y - s * 0.2); + break; + case 'coins': + g.fillCircle(x - s * 0.3, y + s * 0.2, s * 0.45); + g.fillCircle(x + s * 0.35, y + s * 0.15, s * 0.45); + g.fillCircle(x + 0, y - s * 0.35, s * 0.45); + break; + case 'ankh': + g.fillCircle(x, y - s * 0.45, s * 0.34); + g.fillStyle(0x000000, 0.001); // punch look via inner stroke instead + g.lineStyle(Math.max(2, s / 3.2), info.color, 1); + g.lineBetween(x, y - s * 0.15, x, y + s); + g.lineBetween(x - s * 0.5, y + s * 0.15, x + s * 0.5, y + s * 0.15); + break; + default: break; + } + cont.add(g); + } + + // ── card renderers (Boss-Monster-style procedural frames) ────────────────── + makeRoomCard(x, y, inst, w, h, opts = {}) { + const def = roomDef(inst); + const cont = this.add.container(x, y); + const trap = def.type === 'trap'; + const edge = opts.selected ? C.gold : (trap ? C.trapEdge : C.monsterEdge); + const plate = trap ? C.trapPlate : C.monsterPlate; + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(Math.max(2, w / 55), edge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + if (def.advanced) { + g.lineStyle(1.5, C.gold, 0.9); + g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 4); + for (const [gx, gy] of [[-w / 2 + 9, -h / 2 + 9], [w / 2 - 9, -h / 2 + 9], [-w / 2 + 9, h / 2 - 9], [w / 2 - 9, h / 2 - 9]]) { + g.fillStyle(C.gold, 1); g.fillCircle(gx, gy, Math.max(2.5, w / 60)); + } + } + // name plate + const plateH = Math.max(16, h * 0.17); + g.fillStyle(plate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3); + // art window + const artH = h * (opts.showText ? 0.42 : 0.58); + g.fillStyle(C.artWindow, 1); + g.fillRect(-w / 2 + 7, -h / 2 + plateH + 8, w - 14, artH); + cont.add(g); + + const art = this.artFor('room', inst.id); + const artCY = -h / 2 + plateH + 8 + artH / 2; + if (art) { + const img = this.add.image(0, artCY, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(0, artCY, trap ? '⚙' : '👹', { fontSize: `${Math.round(artH * 0.5)}px` }).setOrigin(0.5).setAlpha(0.75)); + } + cont.add(this.add.text(0, -h / 2 + 5 + plateH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 12))}px`, + color: '#f2ead8', wordWrap: { width: w - 14 }, + }).setOrigin(0.5).setScale(Math.min(1, (w - 16) / Math.max(1, def.name.length * (w / 20))))); + + // damage badge (bottom-left) + const bd = this.add.graphics(); + const br = Math.max(9, w / 13); + bd.fillStyle(0x1a1420, 1); bd.fillCircle(-w / 2 + br + 5, h / 2 - br - 5, br + 2); + bd.fillStyle(C.wound, 1); bd.fillCircle(-w / 2 + br + 5, h / 2 - br - 5, br); + cont.add(bd); + const dmgLabel = def.passive === 'ballroomDamage' ? '✷' : `${def.dmg}`; + cont.add(this.add.text(-w / 2 + br + 5, h / 2 - br - 5, dmgLabel, { + fontFamily: 'Righteous', fontSize: `${Math.round(br * 1.1)}px`, color: '#f2ead8', + }).setOrigin(0.5)); + + // treasure icons (bottom-right) + const icons = []; + for (const [cls, n] of Object.entries(def.treasure || {})) for (let i = 0; i < n; i++) icons.push(cls); + const isz = Math.max(6, w / 22); + icons.forEach((cls, i) => { + this.drawTreasureIcon(cont, w / 2 - 12 - i * (isz * 2 + 4), h / 2 - isz - 6, cls, isz); + }); + + if (opts.showText && def.text) { + const boxTop = -h / 2 + plateH + 12 + artH; + const tg = this.add.graphics(); + tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 26, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 26) / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.ink, align: 'center', + wordWrap: { width: w - 22 }, + }).setOrigin(0.5)); + } + this.finishCard(cont, w, h, opts); + return cont; + } + + makeSpellCard(x, y, inst, w, h, opts = {}) { + const def = spellDef(inst); + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(Math.max(2, w / 45), opts.selected ? C.gold : C.spellEdge, 1); + g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + const plateH = Math.max(15, h * 0.15); + g.fillStyle(C.spellPlate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3); + const artH = h * 0.36; + g.fillStyle(0x201640, 1); g.fillRect(-w / 2 + 7, -h / 2 + plateH + 8, w - 14, artH); + cont.add(g); + const art = this.artFor('spell', inst.id); + const artCY = -h / 2 + plateH + 8 + artH / 2; + if (art) { + const img = this.add.image(0, artCY, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(0, artCY, '✦', { fontSize: `${Math.round(artH * 0.55)}px`, color: '#8f6fd8' }).setOrigin(0.5)); + } + cont.add(this.add.text(0, -h / 2 + 5 + plateH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 8.5))}px`, color: '#f2ead8', + }).setOrigin(0.5).setScale(Math.min(1, (w - 14) / Math.max(1, def.name.length * (w / 14))))); + const phase = def.phase === 'both' ? 'BUILD · ADV' : def.phase.toUpperCase(); + cont.add(this.add.text(0, -h / 2 + plateH + artH + 16, phase, { + fontFamily: '"Julius Sans One"', fontSize: '10px', color: C.spellPhaseDarkHex, + }).setOrigin(0.5)); + const boxTop = -h / 2 + plateH + artH + 26; + const tg = this.add.graphics(); + tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', + wordWrap: { width: w - 20 }, + }).setOrigin(0.5)); + this.finishCard(cont, w, h, opts); + return cont; + } + + makeHeroCard(x, y, hero, w, h, opts = {}) { + const def = heroDef(hero); + const info = CLASS_INFO[def.cls] || { color: 0x888888, label: 'Wanderer' }; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(Math.max(2, w / 40), opts.selected ? C.gold : info.color, 1); + g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + if (def.epic) { g.lineStyle(2, C.gold, 1); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 4); } + const artH = h * 0.52; + g.fillStyle(C.heroWindow, 1); g.fillRect(-w / 2 + 6, -h / 2 + 6, w - 12, artH); + // class banner + g.fillStyle(info.color, 1); g.fillRoundedRect(-w / 2 + 6, -h / 2 + artH + 10, w - 12, 16, 3); + cont.add(g); + const art = this.artFor('hero', hero.id); + if (art) { + const img = this.add.image(0, -h / 2 + 6 + artH / 2, art.key, art.frame); + img.setScale(Math.min((w - 12) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + const glyph = def.fool ? '🃏' : def.cls === 'fighter' ? '🛡' : def.cls === 'mage' ? '🔮' : def.cls === 'thief' ? '🗝' : '📿'; + cont.add(this.add.text(0, -h / 2 + 6 + artH / 2, glyph, { fontSize: `${Math.round(artH * 0.42)}px` }).setOrigin(0.5).setAlpha(0.85)); + } + if (def.epic) { + cont.add(this.add.text(0, -h / 2 + 14, '★ EPIC', { fontFamily: 'Righteous', fontSize: '11px', color: C.goldHex }).setOrigin(0.5)); + } + cont.add(this.add.text(0, -h / 2 + artH + 18, (def.fool ? 'THE FOOL' : info.label.toUpperCase()), { + fontFamily: 'Righteous', fontSize: '11px', color: '#f2ead8', + }).setOrigin(0.5)); + cont.add(this.add.text(0, -h / 2 + artH + 38, def.name, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', + wordWrap: { width: w - 12 }, + }).setOrigin(0.5)); + // hp heart + soul value + const hy = h / 2 - 16; + const hg = this.add.graphics(); + hg.fillStyle(C.wound, 1); + hg.fillCircle(-w / 2 + 16, hy - 3, 6); hg.fillCircle(-w / 2 + 26, hy - 3, 6); + hg.fillTriangle(-w / 2 + 9, hy - 1, -w / 2 + 33, hy - 1, -w / 2 + 21, hy + 11); + cont.add(hg); + cont.add(this.add.text(-w / 2 + 21, hy + 1, `${hero.hp}`, { + fontFamily: 'Righteous', fontSize: '13px', color: '#fff', + }).setOrigin(0.5)); + const souls = heroSouls(def); + const sg = this.add.graphics(); + sg.fillStyle(C.soul, 1); sg.fillCircle(w / 2 - 18, hy + 2, 9); + sg.lineStyle(1.5, 0x8a6a14, 1); sg.strokeCircle(w / 2 - 18, hy + 2, 9); + cont.add(sg); + cont.add(this.add.text(w / 2 - 18, hy + 2, `${souls}`, { + fontFamily: 'Righteous', fontSize: '12px', color: '#2a2118', + }).setOrigin(0.5)); + this.finishCard(cont, w, h, opts); + return cont; + } + + makeBossCard(x, y, bossId, w, h, opts = {}) { + const def = BOSSES[bossId]; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(Math.max(2.5, w / 40), C.bossEdge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(1.5, C.gold, 0.7); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 5); + const artH = h * (opts.showText ? 0.44 : 0.6); + g.fillStyle(0x0d0a12, 1); g.fillRect(-w / 2 + 7, -h / 2 + 30, w - 14, artH); + cont.add(g); + const art = this.artFor('boss', bossId); + if (art) { + const img = this.add.image(0, -h / 2 + 30 + artH / 2, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(0, -h / 2 + 30 + artH / 2, '☠', { fontSize: `${Math.round(artH * 0.5)}px`, color: '#8c1f28' }).setOrigin(0.5)); + } + cont.add(this.add.text(0, -h / 2 + 16, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(11, Math.round(w / 10))}px`, color: C.goldDarkHex, + }).setOrigin(0.5).setScale(Math.min(1, (w - 12) / Math.max(1, def.name.length * (w / 16))))); + const iy = -h / 2 + 30 + artH + 14; + cont.add(this.add.text(-w / 2 + 10, iy, `${def.xp} XP`, { + fontFamily: 'Righteous', fontSize: '13px', color: C.xpDarkHex, + }).setOrigin(0, 0.5)); + this.drawTreasureIcon(cont, w / 2 - 18, iy, def.treasure, 9); + if (opts.showText) { + const boxTop = iy + 12; + const tg = this.add.graphics(); + tg.fillStyle(C.parchment, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, h / 2 - boxTop - 8, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + (h / 2 - boxTop - 8) / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: C.ink, align: 'center', + wordWrap: { width: w - 20 }, + }).setOrigin(0.5)); + } + this.finishCard(cont, w, h, opts); + return cont; + } + + makeCardBack(x, y, w, h, parent) { + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x241a2e, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(2, 0x4a3a5c, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 6); + g.lineStyle(1, 0x4a3a5c, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 4); + cont.add(g); + cont.add(this.add.text(0, 0, '✦', { fontSize: `${Math.round(h * 0.3)}px`, color: '#5c4a70' }).setOrigin(0.5)); + (parent || this.boardLayer).add(cont); + return cont; + } + + finishCard(cont, w, h, opts) { + if (opts.deactivated) { + const ov = this.add.graphics(); + ov.fillStyle(C.frozen, 0.28); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 6); + cont.add(ov); + cont.add(this.add.text(0, 0, '❄', { fontSize: `${Math.round(h * 0.3)}px`, color: '#d8f0fa' }).setOrigin(0.5).setAlpha(0.9)); + } + if (opts.highlight) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 8); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); + } + if (opts.onClick) { + cont.setSize(w, h); + cont.setInteractive({ useHandCursor: true }); + if (opts.hover !== false) { + const baseY = cont.y; + cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 14, duration: 100 }); }); + cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 })); + } + cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); + } + (opts.parent || this.boardLayer).add(cont); + } + + // ── layout helpers ────────────────────────────────────────────────────────── + humanSlotRect(idx) { + // Boss at the right; slot 0 sits beside it, entrance grows leftward. + return { x: 1500 - idx * 212, y: 700, w: 196, h: 136 }; + } + townPos(i, n) { + const pitch = Math.min(124, 1100 / Math.max(1, n)); + return { x: GAME_WIDTH / 2 - ((n - 1) * pitch) / 2 + i * pitch, y: 408 }; + } + + // ── full re-render ────────────────────────────────────────────────────────── + renderAll() { + this.boardLayer.removeAll(true); + this.townLayer.removeAll(true); + this.handLayer.removeAll(true); + this._heroTokens.clear(); + this._slotRects.clear(); + this._handSprites.clear(); + this._soulsPos.clear(); + const gs = this.gs; + if (!gs) return; + + this.renderOpponents(); + this.renderTown(); + this.renderHumanBoard(); + this.renderHand(); + this.deckText.setText([ + `Rooms ${gs.decks.rooms.length}`, + `Spells ${gs.decks.spells.length}`, + `Heroes ${gs.decks.heroes.length}`, + `Epics ${gs.decks.epics.length}${gs.epicsActive ? ' ⚡' : ''}`, + `Round ${gs.round}`, + ].join('\n')); + } + + renderOpponents() { + const centers = this.oppPanelCenters(); + for (let i = 0; i < this.opponents.length; i++) { + const seat = i + 1; + const p = this.gs.players[seat]; + const { x, y } = centers[i]; + const pw = 560, ph = 224; + const g = this.add.graphics(); + g.fillStyle(0x1a1422, p.alive ? 0.92 : 0.5); + g.fillRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10); + g.lineStyle(2, p.alive ? 0x3a2f4d : 0x2a2233, 1); + g.strokeRoundedRect(x - pw / 2, y - ph / 2, pw, ph, 10); + this.boardLayer.add(g); + const nameCol = p.alive ? '#f2ead8' : '#6f6678'; + this.boardLayer.add(this.add.text(x - 195, y - 62, this.opponents[i].name || `Boss ${seat}`, { + fontFamily: 'Righteous', fontSize: '18px', color: nameCol, + }).setOrigin(0.5)); + if (!p.alive) { + this.boardLayer.add(this.add.text(x, y, 'DEFEATED', { + fontFamily: 'Righteous', fontSize: '34px', color: '#8c1f28', + }).setOrigin(0.5).setAngle(-8)); + continue; + } + // boss mini-card at panel right + this.makeBossCard(x + 228, y - 20, p.boss.id, 88, 124, { + parent: this.boardLayer, hover: false, + onClick: () => this.showInspect('boss', p.boss.id), + }); + // souls & wounds under the name + const sy = y - 20; + const sg = this.add.graphics(); + sg.fillStyle(C.soul, 1); sg.fillCircle(x - 245, sy, 11); + this.boardLayer.add(sg); + this._soulsPos.set(seat, { x: x - 245, y: sy }); + this.boardLayer.add(this.add.text(x - 228, sy, `${p.souls}/${SOULS_TO_WIN}`, { + fontFamily: 'Righteous', fontSize: '17px', color: C.goldHex, + }).setOrigin(0, 0.5)); + for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) { + this.boardLayer.add(this.add.text(x - 250 + wI * 22, sy + 30, '☠', { + fontSize: '17px', color: wI < p.wounds ? '#c0392b' : '#3a3242', + }).setOrigin(0.5)); + } + this.boardLayer.add(this.add.text(x - 245, sy + 56, `✋ ${p.hand.rooms.length + p.hand.spells.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#9e9080', + }).setOrigin(0, 0.5)); + // mini dungeon: boss-adjacent on the right, entrance leftward + const mw = 82, mh = 58, pitch = 88; + for (let idx = 0; idx < p.dungeon.length; idx++) { + const sx = x + 128 - idx * pitch; + const slot = p.dungeon[idx]; + this._slotRects.set(`${seat}:${idx}`, { x: sx, y: y + 62, w: mw, h: mh }); + this.makeRoomCard(sx, y + 62, slot.room, mw, mh, { + parent: this.boardLayer, hover: false, + deactivated: slot.deactivated, + highlight: this.isRoomTarget(seat, idx), + onClick: () => this.onRoomClicked(seat, idx), + }); + if (slot.tempDmg) this.miniBadge(sx + mw / 2 - 8, y + 62 - mh / 2 + 8, `+${slot.tempDmg}`); + } + // pending secret build + if (p.pendingBuild) this.makeCardBack(x + 128 - p.dungeon.length * pitch, y + 62, mw * 0.8, mh * 0.9, this.boardLayer); + // entrance heroes + p.entrance.forEach((hero, hi) => { + const hx = x - pw / 2 + 26 + hi * 20; + const t = this.heroToken(hero, hx, y + 62); + this.boardLayer.add(t); + this._heroTokens.set(hero.uid, { x: hx, y: y + 62 }); + }); + } + } + + renderTown() { + const gs = this.gs; + const n = gs.town.length; + const label = this.add.text(GAME_WIDTH / 2, 318, `— TOWN ${n ? '' : '(empty)'} —`, { + fontFamily: 'Righteous', fontSize: '18px', color: '#9e9080', + }).setOrigin(0.5); + this.townLayer.add(label); + gs.town.forEach((hero, i) => { + const { x, y } = this.townPos(i, n); + this._heroTokens.set(hero.uid, { x, y }); + this.makeHeroCard(x, y, hero, 106, 148, { + parent: this.townLayer, + highlight: this.isHeroTarget(hero.uid), + onClick: () => this.onHeroClicked(hero.uid), + }); + }); + } + + renderHumanBoard() { + const gs = this.gs; + const p = gs.players[this.humanSeat]; + const seat = this.humanSeat; + // boss card + this.makeBossCard(1740, 700, p.boss.id, 168, 236, { + parent: this.boardLayer, showText: true, hover: false, + onClick: () => this.showInspect('boss', p.boss.id), + }); + // souls & wounds beside the player portrait + const sg = this.add.graphics(); + sg.fillStyle(C.soul, 1); sg.fillCircle(60, 780, 13); + this.boardLayer.add(sg); + this._soulsPos.set(seat, { x: 60, y: 780 }); + this.boardLayer.add(this.add.text(82, 780, `${p.souls}/${SOULS_TO_WIN} souls`, { + fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex, + }).setOrigin(0, 0.5)); + for (let wI = 0; wI < WOUNDS_TO_DIE; wI++) { + this.boardLayer.add(this.add.text(52 + wI * 26, 818, '☠', { + fontSize: '21px', color: wI < p.wounds ? '#c0392b' : '#3a3242', + }).setOrigin(0.5)); + } + if (!p.alive) { + this.boardLayer.add(this.add.text(1060, 700, 'YOUR BOSS HAS FALLEN', { + fontFamily: 'Righteous', fontSize: '42px', color: '#8c1f28', + }).setOrigin(0.5).setAngle(-4)); + return; + } + // slots: existing rooms + (in build mode) legal placement outlines + for (let idx = 0; idx < MAX_ROOMS; idx++) { + const r = this.humanSlotRect(idx); + const slot = p.dungeon[idx]; + if (slot) { + this._slotRects.set(`${seat}:${idx}`, r); + this.makeRoomCard(r.x, r.y, slot.room, r.w, r.h, { + parent: this.boardLayer, hover: false, + deactivated: slot.deactivated, + highlight: this.isRoomTarget(seat, idx) || this.isBuildSlot(idx), + onClick: () => this.onRoomClicked(seat, idx), + }); + if (slot.under.length) { + this.boardLayer.add(this.add.text(r.x - r.w / 2 + 10, r.y - r.h / 2 - 10, `▤ ${slot.under.length + 1}`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#9e9080', + }).setOrigin(0, 0.5)); + } + if (slot.tempDmg) this.miniBadge(r.x + r.w / 2 - 10, r.y - r.h / 2 + 10, `+${slot.tempDmg}`); + if (slot.armed) this.miniBadge(r.x, r.y - r.h / 2 + 10, slot.armed.type === 'ramp' ? '⚠+5' : '⚠', 0xc9962a); + // activatable badge during windows + const act = this.activatableFor(idx); + if (act) { + const b = this.add.container(r.x, r.y + r.h / 2 + 16); + const bg = this.add.graphics(); + bg.fillStyle(C.gold, 1); bg.fillRoundedRect(-42, -12, 84, 24, 12); + b.add(bg); + b.add(this.add.text(0, 0, 'USE', { fontFamily: 'Righteous', fontSize: '14px', color: '#2a2118' }).setOrigin(0.5)); + b.setSize(84, 24); + b.setInteractive({ useHandCursor: true }); + b.on('pointerdown', () => { if (!this.busy) this.beginActivate(idx, act); }); + this.boardLayer.add(b); + this.tweens.add({ targets: b, alpha: 0.6, duration: 500, yoyo: true, repeat: -1 }); + } + } else if (this.isBuildSlot(idx)) { + const g = this.add.graphics(); + g.lineStyle(3, C.gold, 0.9); + g.strokeRoundedRect(r.x - r.w / 2, r.y - r.h / 2, r.w, r.h, 8); + this.boardLayer.add(g); + this.tweens.add({ targets: g, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); + const z = this.add.zone(r.x, r.y, r.w, r.h).setInteractive({ useHandCursor: true }); + z.on('pointerdown', () => this.onBuildSlotClicked(idx)); + this.boardLayer.add(z); + } else if (idx === p.dungeon.length) { + const g = this.add.graphics(); + g.lineStyle(2, 0x3a2f4d, 0.8); + g.strokeRoundedRect(r.x - r.w / 2, r.y - r.h / 2, r.w, r.h, 8); + this.boardLayer.add(g); + } + } + // entrance arch + queued heroes + const entR = this.humanSlotRect(Math.max(p.dungeon.length, 1) - 0.35); + this.boardLayer.add(this.add.text(entR.x - 130, 700, '⛩', { fontSize: '44px' }).setOrigin(0.5).setAlpha(0.8)); + this.boardLayer.add(this.add.text(entR.x - 130, 748, 'ENTRANCE', { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#9e9080', + }).setOrigin(0.5)); + p.entrance.forEach((hero, hi) => { + const hx = entR.x - 130 - hi * 24; + const t = this.heroToken(hero, hx, 640); + this.boardLayer.add(t); + this._heroTokens.set(hero.uid, { x: hx, y: 640 }); + }); + // pending build indicator + if (p.pendingBuild) { + const r = this.humanSlotRect(p.pendingBuild.slotIdx); + this.makeCardBack(r.x, r.y - 20, r.w * 0.85, r.h * 0.9, this.boardLayer).setAlpha(0.9); + } + } + + heroToken(hero, x, y) { + const def = heroDef(hero); + const info = CLASS_INFO[def.cls] || { color: 0x888888 }; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x10141c, 1); g.fillCircle(0, 0, 17); + g.lineStyle(def.epic ? 3 : 2, def.epic ? C.gold : info.color, 1); g.strokeCircle(0, 0, 17); + cont.add(g); + cont.add(this.add.text(0, -1, `${hero.hp}`, { fontFamily: 'Righteous', fontSize: '15px', color: '#f2ead8' }).setOrigin(0.5)); + cont.setSize(36, 36); + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerdown', () => { if (!this.busy) this.onHeroClicked(hero.uid); }); + if (this.isHeroTarget(hero.uid)) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); hl.strokeCircle(0, 0, 22); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.3, duration: 420, yoyo: true, repeat: -1 }); + } + return cont; + } + + miniBadge(x, y, txt, color = 0x8f6fd8) { + const g = this.add.graphics(); + g.fillStyle(color, 1); g.fillRoundedRect(x - 17, y - 9, 34, 18, 9); + this.boardLayer.add(g); + this.boardLayer.add(this.add.text(x, y, txt, { + fontFamily: 'Righteous', fontSize: '12px', color: '#f2ead8', + }).setOrigin(0.5)); + } + + renderHand() { + const p = this.gs.players[this.humanSeat]; + if (!p.alive) return; + const rooms = p.hand.rooms; + const spells = p.hand.spells; + const total = rooms.length + spells.length; + if (!total) return; + const roomW = 148, roomH = 102, spellW = 104, spellH = 146; + const pitch = Math.min(160, 1500 / Math.max(1, total)); + const width = (total - 1) * pitch; + let x = GAME_WIDTH / 2 - width / 2; + const y = 985; + const mode = this.mode; + for (const inst of rooms) { + const selectable = this.isHandSelectable(inst); + const sp = this.makeRoomCard(x, y, inst, roomW, roomH, { + parent: this.handLayer, showText: false, + selected: this.isHandSelected(inst.uid), + highlight: selectable && (mode.type === 'discard' || mode.type === 'setupDiscard'), + onClick: () => this.onHandClicked(inst), + }); + if (!selectable && (mode.type === 'build' || mode.type === 'discard' || mode.type === 'setupDiscard' || mode.type === 'window')) sp.setAlpha(0.85); + this._handSprites.set(inst.uid, sp); + x += pitch; + } + for (const inst of spells) { + const castable = this.castableUids?.has(inst.uid); + const sp = this.makeSpellCard(x, y - 16, inst, spellW, spellH, { + parent: this.handLayer, + selected: this.isHandSelected(inst.uid), + highlight: !!castable || (this.isHandSelectable(inst) && (mode.type === 'discard' || mode.type === 'setupDiscard')), + onClick: () => this.onHandClicked(inst), + }); + this._handSprites.set(inst.uid, sp); + x += pitch; + } + } + + // ── mode / highlight queries ──────────────────────────────────────────────── + isBuildSlot(idx) { + return this.mode.type === 'build' && this.mode.selectedRoom + && this.mode.legal.some((b) => b.roomUid === this.mode.selectedRoom && b.slotIdx === idx); + } + isRoomTarget(seat, idx) { + return (this.mode.type === 'target' || this.mode.type === 'castTarget' || this.mode.type === 'activateTarget') + && (this.mode.candidates || []).some((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx) + || (c.kind === 'swap' && c.seat === seat && (c.a === idx || c.b === idx))); + } + isHeroTarget(uid) { + return (this.mode.type === 'target' || this.mode.type === 'castTarget' || this.mode.type === 'activateTarget') + && (this.mode.candidates || []).some((c) => c.kind === 'hero' && c.uid === uid); + } + isHandSelected(uid) { return (this.mode.selected || []).includes(uid) || this.mode.selectedRoom === uid; } + isHandSelectable(inst) { + const m = this.mode; + if (m.type === 'setupDiscard' || m.type === 'discard') { + if (m.cardType === 'spell') return !!SPELLS[inst.id]; + if (m.cardType === 'room') return !!ROOMS[inst.id]; + return true; + } + if (m.type === 'build') return !!ROOMS[inst.id] && m.legal.some((b) => b.roomUid === inst.uid); + if (m.type === 'window') return this.castableUids?.has(inst.uid); + return false; + } + + // ── pump: run engine until human input is required ───────────────────────── + pump() { + if (!this.gs || this.busy) return; + if (this.gs.gameOver) { this.onGameOver(); return; } + const d = pendingDecision(this.gs); + if (!d) return; + if (d.seat !== this.humanSeat) { + this.setPrompt(this.aiPromptFor(d)); + const skill = this.opponents[d.seat - 1]?.skill ?? 3; + this.busy = true; + this.time.delayedCall(nextThinkDelay(skill), () => { + this.busy = false; + if (this.gs.gameOver) { this.onGameOver(); return; } + const nd = pendingDecision(this.gs); + if (!nd || nd.seat === this.humanSeat) { this.pump(); return; } + const choice = decide(publicView(this.gs, nd.seat), nd, skill); + this.applyDecision(nd, choice); + }); + return; + } + this.enterHumanMode(d); + } + + aiPromptFor(d) { + const name = this.opponents[d.seat - 1]?.name || `Boss ${d.seat}`; + switch (d.kind) { + case 'build': return `${name} is scheming a room…`; + case 'window': return `${name} is considering spells…`; + case 'react': return `${name} eyes the spell…`; + default: return `${name} is thinking…`; + } + } + + applyDecision(d, choice) { + try { + switch (d.kind) { + case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break; + case 'build': actBuild(this.gs, d.seat, choice); break; + case 'window': actWindow(this.gs, d.seat, choice); break; + case 'react': actReact(this.gs, d.seat, choice); break; + case 'target': actChooseTarget(this.gs, d.seat, choice); break; + case 'discard': actDiscard(this.gs, d.seat, choice); break; + case 'roomDraw': actRoomDraw(this.gs, d.seat, choice); break; + default: break; + } + } catch (err) { + // A decision the engine rejects should never soft-lock the table. + console.error('dungeonboss action rejected:', err); + this.renderAll(); + this.pump(); + return; + } + this.mode = { type: 'idle' }; + this.castableUids = null; + this.clearActionButtons(); + this.playEvents(takeEvents(this.gs)); + } + + // ── human decision modes ──────────────────────────────────────────────────── + enterHumanMode(d) { + this.clearActionButtons(); + switch (d.kind) { + case 'setupDiscard': + this.mode = { type: 'setupDiscard', selected: [], n: 2 }; + this.setPrompt('Discard 2 cards — click cards, then Confirm'); + this.addActionButton('Confirm', () => { + if (this.mode.selected.length === 2) this.applyDecision(d, this.mode.selected); + }); + break; + case 'discard': + this.mode = { type: 'discard', selected: [], n: d.n, cardType: d.cardType }; + this.setPrompt(`Discard ${d.n} ${d.cardType} card${d.n > 1 ? 's' : ''}`); + this.addActionButton('Confirm', () => { + if (this.mode.selected.length === d.n) this.applyDecision(d, this.mode.selected); + }); + break; + case 'build': { + const legal = legalBuilds(this.gs, this.humanSeat); + this.mode = { type: 'build', legal, selectedRoom: null, decision: d }; + if (d.setup) this.setPrompt('Build your first room — click a room card, then a slot'); + else if (d.extra) this.setPrompt('Extra build! Click a room card, then a slot'); + else this.setPrompt('Build a room (click card, then slot) — or Pass'); + if (!d.setup || !legal.length) this.addActionButton('Pass', () => this.applyDecision(d, null)); + break; + } + case 'window': { + const acts = windowActions(this.gs, this.humanSeat); + this.mode = { type: 'window', decision: d, acts }; + this.castableUids = new Set(acts.spells.map((s) => s.uid)); + const label = d.window === 'advStart' + ? (d.advSeat === this.humanSeat ? 'Heroes charge your gate! Cast spells or Pass' + : `Heroes storm ${this.seatName(d.advSeat)} — interfere or Pass`) + : 'Spell window — cast, use a room, or Pass'; + this.setPrompt(label); + this.addActionButton('Pass', () => this.applyDecision(d, { pass: true })); + break; + } + case 'react': { + this.mode = { type: 'react', decision: d }; + this.showReactModal(d); + break; + } + case 'target': { + this.mode = { type: 'target', decision: d, candidates: d.candidates }; + this.setPrompt(this.targetPrompt(d)); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + if (this.needsModalTargets(d)) this.showTargetModal(d); + break; + } + case 'roomDraw': { + this.mode = { type: 'roomDraw', decision: d }; + this.setPrompt('Haunted Library: choose your draw'); + this.addActionButton('Draw Room', () => this.applyDecision(d, 'room')); + this.addActionButton('Draw Spell', () => this.applyDecision(d, 'spell')); + break; + } + default: break; + } + this.renderAll(); + } + + seatName(seat) { + return seat === this.humanSeat ? 'you' : (this.opponents[seat - 1]?.name || `Boss ${seat}`); + } + + targetPrompt(d) { + switch (d.op) { + case 'destroyOwnRoom': return 'Robobo strikes! Choose one of your rooms to demolish'; + case 'killHeroTown': return 'Choose a hero in town to petrify'; + case 'lureHero': return 'Choose a hero to lure to your entrance'; + case 'swapRooms': return 'Choose a room pair to swap'; + case 'tutorAdvanced': return 'Choose an advanced room to build'; + case 'recoverDiscard': return 'Reclaim a card from the discard pile'; + case 'revealTake': return 'Take a card from a rival\'s hand'; + case 'stealRandom': case 'opponentDiscardRandom': return 'Choose a rival'; + default: return 'Choose a target'; + } + } + + // Ops whose candidates aren't on the board (cards in discards/hands/decks). + needsModalTargets(d) { + return ['recoverDiscard', 'revealTake', 'tutorAdvanced'].includes(d.op) + || (d.op === 'lureHero' && (d.candidates || []).some((c) => c.kind === 'deckHero')) + || ['stealRandom', 'opponentDiscardRandom'].includes(d.op) + || (d.candidates || []).every((c) => c.kind === 'soul' || c.kind === 'player'); + } + + // ── click handlers ────────────────────────────────────────────────────────── + onHandClicked(inst) { + const m = this.mode; + if (m.type === 'setupDiscard' || m.type === 'discard') { + if (!this.isHandSelectable(inst)) return; + const i = m.selected.indexOf(inst.uid); + if (i >= 0) m.selected.splice(i, 1); + else if (m.selected.length < m.n) m.selected.push(inst.uid); + this.sfx(SFX.CARD_SHOW); + this.renderAll(); + return; + } + if (m.type === 'build') { + if (!ROOMS[inst.id] || !m.legal.some((b) => b.roomUid === inst.uid)) return; + m.selectedRoom = m.selectedRoom === inst.uid ? null : inst.uid; + this.sfx(SFX.CARD_SHOW); + this.renderAll(); + return; + } + if (m.type === 'window' && this.castableUids?.has(inst.uid)) { + const act = m.acts.spells.find((s) => s.uid === inst.uid); + if (act.targets === null) { + this.applyDecision(m.decision, { spellUid: inst.uid }); + } else { + this.mode = { type: 'castTarget', decision: m.decision, spellUid: inst.uid, candidates: act.targets }; + this.setPrompt(`${SPELLS[inst.id].name}: choose a target`); + this.clearActionButtons(); + this.addActionButton('Cancel', () => this.enterHumanMode(m.decision)); + if (act.targets.every((c) => ['card', 'soul', 'player', 'deckHero', 'build', 'swap'].includes(c.kind))) { + this.showPickModal(act.targets, (t) => this.applyDecision(this.mode.decision, { spellUid: inst.uid, target: t })); + } + this.renderAll(); + } + } + } + + onBuildSlotClicked(idx) { + const m = this.mode; + if (m.type !== 'build' || !m.selectedRoom) return; + this.sfx(SFX.CARD_PLACE); + this.applyDecision(m.decision, { roomUid: m.selectedRoom, slotIdx: idx }); + } + + onRoomClicked(seat, idx) { + const m = this.mode; + const ref = this.matchCandidate((c) => (c.kind === 'room' && c.seat === seat && c.slotIdx === idx) + || (c.kind === 'swap' && c.seat === seat && (c.a === idx || c.b === idx))); + if (ref && ref.kind === 'swap') { + // two-click swap: first click picks `a`, second picks the pair + if (m.swapFirst == null) { m.swapFirst = idx; this.setPrompt('Now choose the room to swap with'); return; } + const pair = (m.candidates || []).find((c) => c.kind === 'swap' && c.seat === seat + && ((c.a === m.swapFirst && c.b === idx) || (c.a === idx && c.b === m.swapFirst))); + if (pair) this.resolveTargetClick(pair); + return; + } + if (ref) { this.resolveTargetClick(ref); return; } + // build-over: clicking an occupied slot while a room is armed + if (m.type === 'build' && m.selectedRoom && seat === this.humanSeat + && m.legal.some((b) => b.roomUid === m.selectedRoom && b.slotIdx === idx)) { + this.onBuildSlotClicked(idx); + return; + } + // otherwise: inspect + const slot = this.gs.players[seat].dungeon[idx]; + if (slot) this.showInspect('room', slot.room.id); + } + + onHeroClicked(uid) { + const ref = this.matchCandidate((c) => c.kind === 'hero' && c.uid === uid); + if (ref) { this.resolveTargetClick(ref); return; } + const hero = this.findHero(uid); + if (hero) this.showInspect('hero', hero.id); + } + + matchCandidate(fn) { + const m = this.mode; + if (!['target', 'castTarget', 'activateTarget'].includes(m.type)) return null; + return (m.candidates || []).find(fn) || null; + } + + resolveTargetClick(ref) { + const m = this.mode; + if (m.type === 'target') this.applyDecision(m.decision, ref); + else if (m.type === 'castTarget') this.applyDecision(m.decision, { spellUid: m.spellUid, target: ref }); + else if (m.type === 'activateTarget') this.applyDecision(m.decision, { slotIdx: m.slotIdx, effIdx: m.effIdx, target: ref, costUids: m.costUids }); + } + + findHero(uid) { + for (const h of this.gs.town) if (h.uid === uid) return h; + for (const p of this.gs.players) for (const h of p.entrance) if (h.uid === uid) return h; + return null; + } + + activatableFor(idx) { + if (this.mode.type !== 'window') return null; + return (this.mode.acts?.rooms || []).find((r) => r.slotIdx === idx) || null; + } + + beginActivate(idx, act) { + const d = this.mode.decision; + const slot = this.gs.players[this.humanSeat].dungeon[idx]; + const eff = roomDef(slot.room).effects[act.effIdx]; + const costUids = this.autoCostUids(eff.cost); + if (act.targets === null) { + this.applyDecision(d, { slotIdx: idx, effIdx: act.effIdx, costUids }); + return; + } + this.mode = { type: 'activateTarget', decision: d, slotIdx: idx, effIdx: act.effIdx, costUids, candidates: act.targets }; + this.setPrompt(`${roomDef(slot.room).name}: choose a target`); + this.clearActionButtons(); + this.addActionButton('Cancel', () => this.enterHumanMode(d)); + if (act.targets.every((c) => ['card', 'soul', 'player', 'deckHero', 'build'].includes(c.kind))) { + this.showPickModal(act.targets, (t) => this.applyDecision(d, { slotIdx: idx, effIdx: act.effIdx, target: t, costUids })); + } + this.renderAll(); + } + + // Cheapest valid cards to pay a cost (humans aren't prompted for cost picks). + autoCostUids(cost) { + if (!cost) return undefined; + const p = this.gs.players[this.humanSeat]; + if (cost.discardRooms) { + return p.hand.rooms.slice().sort((a, b) => roomDef(a).dmg - roomDef(b).dmg) + .slice(0, cost.discardRooms).map((c) => c.uid); + } + if (cost.discardMonsterRoom) { + const m = p.hand.rooms.filter((c) => roomDef(c).type === 'monster') + .sort((a, b) => roomDef(a).dmg - roomDef(b).dmg)[0]; + return m ? [m.uid] : undefined; + } + if (cost.discardSpell) return p.hand.spells.length ? [p.hand.spells[0].uid] : undefined; + return undefined; + } + + // ── modals ────────────────────────────────────────────────────────────────── + modalRoot() { + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6); + dim.setInteractive(); // swallow clicks + root.add(dim); + this._modal = root; + return root; + } + closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } } + + // Generic candidate picker for off-board targets. + showPickModal(cands, onPick) { + const root = this.modalRoot(); + const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(cands.length)))); + const cw = 150, ch = 168; + const rows = Math.ceil(cands.length / cols); + const x0 = GAME_WIDTH / 2 - ((cols - 1) * (cw + 14)) / 2; + const y0 = GAME_HEIGHT / 2 - ((rows - 1) * (ch + 16)) / 2; + cands.forEach((ref, i) => { + const x = x0 + (i % cols) * (cw + 14); + const y = y0 + Math.floor(i / cols) * (ch + 16); + const pick = () => { this.closeModal(); onPick(ref); }; + const card = this.renderCandidate(ref, x, y, cw, ch, root, pick); + if (!card) { + const b = new Button(this, x, y, this.candidateLabel(ref), pick, { width: cw, fontSize: 16 }); + b.setDepth(DEPTH.overlay + 1); + } + }); + const cancel = this.mode.decision?.optional; + new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + (rows / 2) * (ch + 16) + 60, + cancel ? 'Skip' : 'Cancel', () => { + this.closeModal(); + if (cancel) this.applyDecision(this.mode.decision, null); + else this.enterHumanMode(this.mode.decision); + }, { width: 150, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + } + + renderCandidate(ref, x, y, cw, ch, root, onPick) { + const gs = this.gs; + if (ref.kind === 'card') { + const inst = [...gs.decks.roomDiscard, ...gs.decks.spellDiscard, + ...gs.players.flatMap((p) => [...p.hand.rooms, ...p.hand.spells])] + .find((c) => c.uid === ref.uid); + if (!inst) return null; + if (ROOMS[inst.id]) return this.makeRoomCard(x, y, inst, cw, ch * 0.72, { parent: root, showText: false, onClick: onPick, hover: false }); + return this.makeSpellCard(x, y, inst, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false }); + } + if (ref.kind === 'soul') { + const hero = gs.players[ref.seat].soulCards.find((h) => h.uid === ref.uid); + if (!hero) return null; + return this.makeHeroCard(x, y, hero, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false }); + } + if (ref.kind === 'deckHero') { + const hero = [...gs.decks.heroes, ...gs.decks.epics].find((h) => h.uid === ref.uid); + if (!hero) return null; + return this.makeHeroCard(x, y, hero, cw * 0.72, ch, { parent: root, onClick: onPick, hover: false }); + } + if (ref.kind === 'build') { + const inst = [...gs.decks.rooms, ...gs.decks.roomDiscard].find((c) => c.uid === ref.roomUid); + if (!inst) return null; + const card = this.makeRoomCard(x, y - 12, inst, cw, ch * 0.66, { parent: root, showText: false, onClick: onPick, hover: false }); + root.add(this.add.text(x, y + ch / 2 - 14, `→ slot ${ref.slotIdx + 1}`, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: C.goldHex, + }).setOrigin(0.5)); + return card; + } + return null; + } + + candidateLabel(ref) { + if (ref.kind === 'player') return this.seatName(ref.seat); + if (ref.kind === 'swap') return `Swap ${ref.a + 1} ↔ ${ref.b + 1} (${this.seatName(ref.seat)})`; + return 'Choose'; + } + + showReactModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const g = this.add.graphics(); + g.fillStyle(0x1a1422, 0.97); g.fillRoundedRect(cx - 330, cy - 150, 660, 300, 12); + g.lineStyle(3, C.spellEdge, 1); g.strokeRoundedRect(cx - 330, cy - 150, 660, 300, 12); + root.add(g); + root.add(this.add.text(cx, cy - 110, `${this.seatName(d.casterSeat)} casts ${SPELLS[d.spellId].name}!`, { + fontFamily: 'Righteous', fontSize: '26px', color: '#f2ead8', + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 66, SPELLS[d.spellId].text, { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: '#b7a6e8', align: 'center', + wordWrap: { width: 560 }, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const buttons = []; + const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell'); + if (cs) buttons.push({ label: 'Counterspell!', resp: { type: 'counterspell', spellUid: cs.uid } }); + p.dungeon.forEach((slot, slotIdx) => { + if (slot.deactivated) return; + const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction'); + if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length) { + const spare = p.hand.spells.find((c) => c.uid !== cs?.uid) || p.hand.spells[0]; + buttons.push({ label: 'All-Seeing Eye (toss a spell)', resp: { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid } }); + } + }); + buttons.push({ label: 'Let it resolve', resp: null }); + buttons.forEach((b, i) => { + new Button(this, cx, cy + 4 + i * 54, b.label, () => { this.closeModal(); this.applyDecision(d, b.resp); }, + { width: 380, fontSize: 19, variant: b.resp ? undefined : 'ghost' }).setDepth(DEPTH.overlay + 1); + }); + } + + showInspect(kind, id) { + if (this._modal) return; + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const inst = { uid: -1, id, hp: kind === 'hero' ? HEROES[id]?.hp : 0, hpMax: 0 }; + if (kind === 'room') this.makeRoomCard(cx, cy, inst, 380, 300, { parent: root, showText: true, hover: false }); + else if (kind === 'hero') this.makeHeroCard(cx, cy, inst, 280, 390, { parent: root, hover: false }); + else if (kind === 'boss') this.makeBossCard(cx, cy, id, 300, 430, { parent: root, showText: true, hover: false }); + const zone = this.add.zone(cx, cy, GAME_WIDTH, GAME_HEIGHT).setInteractive(); + zone.on('pointerdown', () => this.closeModal()); + root.add(zone); + root.bringToTop(zone); + // let the card render above the dim but below the close zone… close on any click + } + + // ── prompt + action buttons ───────────────────────────────────────────────── + setPrompt(txt) { this.promptText.setText(txt || ''); } + addActionButton(label, cb) { + this._actionButtons = this._actionButtons || []; + const i = this._actionButtons.length; + const b = new Button(this, GAME_WIDTH / 2 + 470 + (i % 2) * 160, 262 + Math.floor(i / 2) * 48, label, cb, + { width: 150, fontSize: 18 }); + b.setDepth(DEPTH.ui); + this._actionButtons.push(b); + } + clearActionButtons() { + for (const b of this._actionButtons || []) b.destroy(); + this._actionButtons = []; + } + + // ── event playback ────────────────────────────────────────────────────────── + playEvents(events) { + this.busy = true; + this.setPrompt(''); + const queue = events.filter((e) => this.eventDelay(e) > 0 || this.eventFx(e, true)); + const step = () => { + const e = queue.shift(); + if (!e) { + this.busy = false; + this.renderAll(); + this.pump(); + return; + } + this.eventFx(e, false); + this.time.delayedCall(this.eventDelay(e), step); + }; + // render intermediate state once so effects anchor to fresh positions + this.renderAll(); + step(); + } + + eventDelay(e) { + switch (e.type) { + case 'flip': return 260; + case 'roomBuilt': return 240; + case 'heroRevealed': return 140; + case 'heroWalks': return 320; + case 'heroEnters': return 200; + case 'roomHits': return 340; + case 'heroDies': return 480; + case 'bossWounded': return 420; + case 'levelUp': return 800; + case 'eliminated': return 800; + case 'spellCast': return 820; + case 'spellCountered': return 700; + case 'roundStart': return 600; + case 'heroResurrected': return 400; + case 'roomDestroyed': return 320; + case 'roomFrozen': return 300; + case 'trapArmed': return 260; + default: return 0; + } + } + + // When probe=true just report whether the event produces fx. + eventFx(e, probe) { + if (probe) return this.eventDelay(e) > 0; + const gs = this.gs; + switch (e.type) { + case 'flip': + this.sfx(SFX.CARD_SHOW); + break; + case 'roomBuilt': { + this.sfx(SFX.CARD_PLACE); + const r = this.slotRect(e.seat, e.slotIdx); + if (r) this.popText(r.x, r.y, ROOMS[e.id]?.name || '', '#f2ead8'); + break; + } + case 'heroRevealed': + this.sfx(SFX.CARD_DEAL); + break; + case 'heroWalks': { + const from = this._heroTokens.get(e.uid); + const hero = this.findHero(e.uid) || { uid: e.uid, id: e.id, hp: 0 }; + if (from) { + const tok = this.heroToken(hero, from.x, from.y); + this.fxLayer.add(tok); + const to = this.entranceApprox(e.seat); + this.tweens.add({ targets: tok, x: to.x, y: to.y, duration: 300, ease: 'Cubic.easeIn', onComplete: () => tok.destroy() }); + } + break; + } + case 'roomHits': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) { + this.popText(r.x, r.y - 20, `-${e.amount}`, '#ff6b5e', 26); + this.shakeAt(r.x, r.y); + } + break; + } + case 'heroDies': { + const from = this._heroTokens.get(e.uid) || this.entranceApprox(e.seat); + const to = this._soulsPos.get(e.seat) || { x: 60, y: 780 }; + const wisp = this.add.graphics(); + wisp.fillStyle(C.soul, 1); wisp.fillCircle(0, 0, 10); + const cont = this.add.container(from.x, from.y, [wisp]); + this.fxLayer.add(cont); + this.tweens.add({ targets: cont, x: to.x, y: to.y, duration: 440, ease: 'Cubic.easeInOut', onComplete: () => cont.destroy() }); + this.popText(from.x, from.y - 26, `+${e.souls} soul${e.souls > 1 ? 's' : ''}`, '#f5d76e'); + this.sfx(SFX.COINS); + break; + } + case 'bossWounded': { + const pos = e.seat === this.humanSeat ? { x: 1740, y: 700 } : this.oppBossPos(e.seat); + this.popText(pos.x, pos.y - 40, `☠ wound${e.wounds > 1 ? ' ×2' : ''}`, '#ff5348', 26); + const flash = this.add.rectangle(pos.x, pos.y, 180, 250, 0xc0392b, 0.4).setDepth(DEPTH.fx); + this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() }); + break; + } + case 'levelUp': + this.showBanner(`⚡ ${BOSSES[e.boss].name} LEVELS UP — ${this.seatName(e.seat)}`); + break; + case 'eliminated': + this.showBanner(`${this.seatName(e.seat)} has been slain!`); + break; + case 'spellCast': { + this.sfx(SFX.CARD_SHOW); + const cx = GAME_WIDTH / 2, cy = 560; + const card = this.makeSpellCard(cx, cy, { uid: -1, id: e.id }, 168, 236, { parent: this.fxLayer, hover: false }); + card.setScale(0.4).setAlpha(0); + this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' }); + this.fxLayer.add(this.add.text(cx, cy - 150, `${this.seatName(e.seat)} casts`, { + fontFamily: 'Righteous', fontSize: '20px', color: '#b7a6e8', + }).setOrigin(0.5)); + this.time.delayedCall(680, () => this.fxLayer.removeAll(true)); + break; + } + case 'spellCountered': + this.showBanner(`✕ ${SPELLS[e.spellId]?.name || 'The spell'} is countered by ${this.seatName(e.seat)}!`); + break; + case 'roundStart': + this.showBanner(`— Round ${e.round} —`); + this.sfx(SFX.CARD_DEAL); + break; + case 'heroResurrected': + this.popText(GAME_WIDTH / 2, 500, 'A hero rises from the grave!', '#9be89b'); + break; + case 'roomDestroyed': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) { this.popText(r.x, r.y, '💥', '#ffffff', 34); this.shakeAt(r.x, r.y); } + break; + } + case 'roomFrozen': { + const r = this.slotRect(e.seat, e.slotIdx); + if (r) this.popText(r.x, r.y, '❄', '#d8f0fa', 34); + break; + } + case 'trapArmed': + this.popText(GAME_WIDTH / 2, 700, '⚠ trap armed', '#c9962a'); + break; + default: break; + } + return true; + } + + slotRect(seat, idx) { + return this._slotRects.get(`${seat}:${idx}`) + || (seat === this.humanSeat ? this.humanSlotRect(idx) : null); + } + entranceApprox(seat) { + if (seat === this.humanSeat) { + const len = this.gs.players[seat].dungeon.length; + const r = this.humanSlotRect(Math.max(0, len - 1)); + return { x: r.x - 130, y: 640 }; + } + const centers = this.oppPanelCenters(); + const c = centers[seat - 1]; + return { x: c.x - 250, y: c.y + 62 }; + } + oppBossPos(seat) { + const c = this.oppPanelCenters()[seat - 1]; + return { x: c.x + 228, y: c.y - 20 }; + } + + popText(x, y, txt, color, size = 20) { + const t = this.add.text(x, y, txt, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, stroke: '#120e16', strokeThickness: 4, + }).setOrigin(0.5).setDepth(DEPTH.fx); + this.tweens.add({ targets: t, y: y - 46, alpha: 0, duration: 900, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + } + + shakeAt(x, y) { + const g = this.add.graphics().setDepth(DEPTH.fx); + g.lineStyle(3, 0xffffff, 0.5); + g.strokeCircle(x, y, 26); + this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() }); + } + + showBanner(text) { + const banner = this.add.text(GAME_WIDTH / 2, 330, text, { + fontFamily: 'Righteous', fontSize: '30px', color: '#f2ead8', + backgroundColor: '#120e16ee', padding: { x: 26, y: 12 }, + }).setOrigin(0.5).setDepth(DEPTH.overlay); + this.tweens.add({ + targets: banner, y: 356, duration: 240, ease: 'Back.easeOut', + onComplete: () => this.time.delayedCall(900, () => + this.tweens.add({ targets: banner, alpha: 0, y: 330, duration: 220, onComplete: () => banner.destroy() })), + }); + } + + sfx(key) { try { playSound(this, key); } catch (_) { /* optional */ } } + + // ── game over ─────────────────────────────────────────────────────────────── + onGameOver() { + if (this._gameOverShown) return; + this._gameOverShown = true; + this.clearActionButtons(); + this.setPrompt(''); + const gs = this.gs; + const won = gs.winner === this.humanSeat; + const human = gs.players[this.humanSeat]; + const oppScores = gs.players.filter((p) => p.seat !== this.humanSeat).map((p) => p.souls); + this.recordResult(won ? 'win' : 'loss', human.souls, oppScores); + + const order = gs.players.slice().sort((a, b) => (b.seat === gs.winner) - (a.seat === gs.winner) || b.souls - a.souls); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const h = 170 + order.length * 44; + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive()); + const g = this.add.graphics(); + g.fillStyle(0x14101c, 0.97); g.fillRoundedRect(cx - 340, cy - h / 2, 680, h, 14); + g.lineStyle(3, won ? C.gold : C.bossEdge, 1); g.strokeRoundedRect(cx - 340, cy - h / 2, 680, h, 14); + root.add(g); + root.add(this.add.text(cx, cy - h / 2 + 40, won ? '👑 Your dungeon reigns!' : 'Your reign is over', { + fontFamily: 'Righteous', fontSize: '36px', color: won ? C.goldHex : '#f2ead8', + }).setOrigin(0.5)); + order.forEach((p, i) => { + const tag = p.seat === gs.winner ? '👑 ' : p.alive ? '' : '☠ '; + root.add(this.add.text(cx, cy - h / 2 + 96 + i * 42, + `${tag}${this.seatName(p.seat)} — ${p.souls} souls, ${p.wounds} wounds`, { + fontFamily: '"Julius Sans One"', fontSize: '21px', + color: p.seat === gs.winner ? C.goldHex : '#e6ddc8', + }).setOrigin(0.5)); + }); + new Button(this, cx - 115, cy + h / 2 - 42, 'Play Again', () => this.scene.restart(this._initData), + { width: 195, fontSize: 21 }).setDepth(DEPTH.overlay + 1); + new Button(this, cx + 115, cy + h / 2 - 42, 'Leave', () => this.scene.start('GameMenu'), + { width: 195, fontSize: 21, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + } + + async recordResult(result, score, opponentScores) { + if (this._recorded) return; + this._recorded = true; + try { + await api.post('/history/single-player', { slug: 'dungeonboss', score, opponentScores, result }); + } catch { /* best effort */ } + } +} diff --git a/src/games/dungeonboss/DungeonBossLogic.js b/src/games/dungeonboss/DungeonBossLogic.js new file mode 100644 index 0000000..c41e147 --- /dev/null +++ b/src/games/dungeonboss/DungeonBossLogic.js @@ -0,0 +1,1375 @@ +// DungeonBossLogic.js +// Pure (no Phaser) engine for Dungeon Boss, a Boss Monster adaptation. +// Pull model: after every act*() mutator the engine advances automatically +// (reveals, draws, bait, adventure walking) until it needs a decision — +// pendingDecision(state) says whose and what kind. Typed events accumulate in +// state.events; the scene/verify drain them with takeEvents(). +// +// Deliberate simplifications from the tabletop game (noted where they apply): +// - Spells resolve at three cast windows (build start, post flip, pre-walk) +// instead of true any-time interrupts; Pit/Ramp/Cave-In "arm" a room and +// resolve when a hero enters it, which is where they'd be used anyway. +// - Counterspell chains don't recurse (a reaction can't itself be countered). +// - Vampire Bordello heals a wound without flipping it to a soul. +// - Trepidation keeps blocked heroes in town rather than parked at an entrance. + +import { + CLASSES, BOSSES, ROOMS, SPELLS, HEROES, OPS, + heroSouls, heroWounds, buildDecks, makeInstance, resetUids, + roomDef, spellDef, heroDef, +} from './DungeonBossData.js'; + +export const SOULS_TO_WIN = 10; +export const WOUNDS_TO_DIE = 5; +export const MAX_ROOMS = 5; +const WINDOW_CAST_CAP = 20; + +// ── RNG (mulberry32, seedable) ────────────────────────────────────────────── +export function makeRng(seed) { + let s = (seed >>> 0) || 1; + const fn = () => { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.int = (n) => Math.floor(fn() * n); + fn.pick = (arr) => arr[fn.int(arr.length)]; + fn.range = (lo, hi) => lo + fn.int(hi - lo + 1); + fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + return fn; +} + +// ── Game creation ─────────────────────────────────────────────────────────── +export function newGame(nPlayers, seed = (Math.random() * 1e9) | 0) { + resetUids(); + const rng = makeRng(seed); + const decks = buildDecks(rng, nPlayers); + const bossIds = rng.shuffle(Object.keys(BOSSES)).slice(0, nPlayers); + const players = []; + for (let seat = 0; seat < nPlayers; seat++) { + players.push({ + seat, + alive: true, + boss: { id: bossIds[seat], leveledUp: false }, + dungeon: [], // index 0 = boss-adjacent; entrance = last index + hand: { rooms: [], spells: [] }, + entrance: [], // heroes queued to walk this round + souls: 0, + soulCards: [], + wounds: 0, + pendingBuild: undefined, // undefined = not asked/answered, null = pass + extraBuilds: 0, + roundMods: freshMods(), + }); + } + const state = { + seed, rng, nPlayers, round: 0, + phase: 'setupDiscard', + decks: { ...decks, roomDiscard: [], spellDiscard: [] }, + epicsActive: false, + town: [], + players, + // Highest XP acts first. Souls tie at game end: LOWEST XP wins. + turnOrder: players.map((p) => p.seat).sort((a, b) => BOSSES[players[b].boss.id].xp - BOSSES[players[a].boss.id].xp), + window: null, + reaction: null, + effectQueue: [], + adv: null, + roundNoBuild: false, + events: [], + gameOver: false, + winner: null, + setupBuilt: false, + }; + for (const p of players) { + for (let i = 0; i < 5; i++) p.hand.rooms.push(state.decks.rooms.pop()); + for (let i = 0; i < 2; i++) p.hand.spells.push(state.decks.spells.pop()); + } + emit(state, { type: 'gameStart', bosses: players.map((p) => p.boss.id) }); + return state; +} + +function freshMods() { + return { allBoost: 0, doubleTreasure: false, blocked: false, extraBuildUsed: false }; +} + +function emit(state, ev) { state.events.push(ev); } +export function takeEvents(state) { const e = state.events; state.events = []; return e; } + +// ── Small helpers ─────────────────────────────────────────────────────────── +const P = (state, seat) => state.players[seat]; +const aliveSeats = (state) => state.turnOrder.filter((s) => P(state, s).alive); +function handAll(p) { return [...p.hand.rooms, ...p.hand.spells]; } +function removeFromHand(p, uid) { + let i = p.hand.rooms.findIndex((c) => c.uid === uid); + if (i >= 0) return p.hand.rooms.splice(i, 1)[0]; + i = p.hand.spells.findIndex((c) => c.uid === uid); + if (i >= 0) return p.hand.spells.splice(i, 1)[0]; + return null; +} +function discardCard(state, inst) { + if (ROOMS[inst.id]) state.decks.roomDiscard.push(inst); + else state.decks.spellDiscard.push(inst); +} +function drawRooms(state, seat, n) { + const p = P(state, seat); + for (let i = 0; i < n; i++) { + if (!state.decks.rooms.length) reshuffleRooms(state); + if (!state.decks.rooms.length) return; + p.hand.rooms.push(state.decks.rooms.pop()); + emit(state, { type: 'draw', seat, card: 'room' }); + } +} +function drawSpells(state, seat, n) { + const p = P(state, seat); + for (let i = 0; i < n; i++) { + if (!state.decks.spells.length) reshuffleSpells(state); + if (!state.decks.spells.length) return; + p.hand.spells.push(state.decks.spells.pop()); + emit(state, { type: 'draw', seat, card: 'spell' }); + } +} +function reshuffleRooms(state) { + if (!state.decks.roomDiscard.length) return; + state.decks.rooms = state.rng.shuffle(state.decks.roomDiscard.filter((c) => ROOMS[c.id])); + state.decks.roomDiscard = []; + emit(state, { type: 'reshuffle', deck: 'rooms' }); +} +function reshuffleSpells(state) { + if (!state.decks.spellDiscard.length) return; + state.decks.spells = state.rng.shuffle(state.decks.spellDiscard); + state.decks.spellDiscard = []; + emit(state, { type: 'reshuffle', deck: 'spells' }); +} + +function newSlot(roomInst) { + return { room: roomInst, under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }; +} + +// ── Treasure / damage math ────────────────────────────────────────────────── +export function treasureCount(state, seat, cls) { + const p = P(state, seat); + let rooms = 0; + for (const slot of p.dungeon) { + if (slot.deactivated) continue; + rooms += (roomDef(slot.room).treasure || {})[cls] || 0; + } + if (p.roundMods.doubleTreasure) rooms *= 2; + return rooms + (BOSSES[p.boss.id].treasure === cls ? 1 : 0); +} + +// Damage a hero takes in dungeon slot `idx` (entrance = dungeon.length-1). +export function roomDamage(state, seat, idx) { + const p = P(state, seat); + const slot = p.dungeon[idx]; + if (!slot || slot.deactivated) return 0; + const def = roomDef(slot.room); + let dmg = def.dmg; + if (def.passive === 'ballroomDamage') { + dmg = p.dungeon.filter((s) => !s.deactivated && roomDef(s.room).type === 'monster').length; + } + if (def.type === 'monster') { + for (const j of [idx - 1, idx + 1]) { + const adj = p.dungeon[j]; + if (adj && !adj.deactivated && roomDef(adj.room).passive === 'adjacentMonsterBoost') dmg += 1; + } + } + // Dizzygas: if the room the hero just left (idx+1, nearer the entrance) is an + // active Dizzygas Hallway and this room is a trap, +2. + const prev = p.dungeon[idx + 1]; + if (def.type === 'trap' && prev && !prev.deactivated && roomDef(prev.room).passive === 'dizzygas') dmg += 2; + dmg += slot.tempDmg + p.roundMods.allBoost; + return Math.max(0, dmg); +} + +// Total damage of a full walk — used by the AI to judge "can I kill this hero". +export function dungeonDamage(state, seat) { + let total = 0; + for (let i = P(state, seat).dungeon.length - 1; i >= 0; i--) total += roomDamage(state, seat, i); + return total; +} + +// ── Legality helpers (shared by scene, AI, verify) ────────────────────────── +export function legalBuilds(state, seat) { + const p = P(state, seat); + const out = []; + for (const inst of p.hand.rooms) { + const def = roomDef(inst); + if (def.advanced) { + for (let idx = 0; idx < p.dungeon.length; idx++) { + if (canBuildOver(state, seat, def, idx)) out.push({ roomUid: inst.uid, slotIdx: idx }); + } + } else { + if (p.dungeon.length < MAX_ROOMS) out.push({ roomUid: inst.uid, slotIdx: p.dungeon.length }); + for (let idx = 0; idx < p.dungeon.length; idx++) out.push({ roomUid: inst.uid, slotIdx: idx }); + } + } + return out; +} +function canBuildOver(state, seat, def, idx) { + const slot = P(state, seat).dungeon[idx]; + if (!slot) return false; + const under = roomDef(slot.room); + if (under.passive === 'noAdvancedOnTop' && def.advanced) return false; + if (!def.advanced) return true; + return CLASSES.some((c) => (def.treasure[c] || 0) > 0 && (under.treasure[c] || 0) > 0); +} + +function windowKindOk(effWindow, windowId) { + if (effWindow === 'any') return true; + if (effWindow === 'build') return windowId === 'buildStart' || windowId === 'postFlip'; + return windowId === 'advStart'; +} +function spellPhaseOk(def, windowId) { + if (def.phase === 'both') return true; + if (def.phase === 'build') return windowId === 'buildStart' || windowId === 'postFlip'; + return windowId === 'advStart'; +} + +// All actions a seat could take in the current window. +export function windowActions(state, seat) { + if (!state.window || state.gameOver) return { spells: [], rooms: [] }; + const p = P(state, seat); + if (!p.alive) return { spells: [], rooms: [] }; + const windowId = state.window.id; + const spells = []; + for (const inst of p.hand.spells) { + const def = spellDef(inst); + if (def.op.op === 'counterSpell') continue; // reaction-only + if (!spellPhaseOk(def, windowId)) continue; + const targets = opCandidates(state, seat, def.op, { source: 'spell' }); + if (targets === null || targets.length) spells.push({ uid: inst.uid, id: inst.id, targets }); + } + const rooms = []; + for (let idx = 0; idx < p.dungeon.length; idx++) { + const slot = p.dungeon[idx]; + if (slot.deactivated || slot.armed) continue; + const def = roomDef(slot.room); + for (let e = 0; e < (def.effects || []).length; e++) { + const eff = def.effects[e]; + if (eff.trigger !== 'activated') continue; + if (!windowKindOk(eff.window, windowId)) continue; + if (eff.oncePerTurn && slot.usedOnce[e]) continue; + if (!costPayable(state, seat, eff.cost, idx)) continue; + const targets = opCandidates(state, seat, eff, { source: 'room', slotIdx: idx }); + if (targets === null || targets.length) rooms.push({ slotIdx: idx, effIdx: e, targets }); + } + } + return { spells, rooms }; +} + +function costPayable(state, seat, cost, slotIdx) { + if (!cost) return true; + const p = P(state, seat); + if (cost.discardRooms && p.hand.rooms.length < cost.discardRooms) return false; + if (cost.discardMonsterRoom && !p.hand.rooms.some((c) => roomDef(c).type === 'monster')) return false; + if (cost.discardSpell && !p.hand.spells.length) return false; + if (cost.destroyOther && p.dungeon.length < 2) return false; + return true; +} + +// ── Op target candidates ──────────────────────────────────────────────────── +// Returns null when the op needs no target, else an array of targetRefs +// (possibly empty = not castable). targetRef kinds: +// {kind:'hero',uid} {kind:'room',seat,slotIdx} {kind:'player',seat} +// {kind:'card',seat,uid} {kind:'soul',seat,uid} {kind:'deckHero',uid} +// {kind:'build',roomUid,slotIdx} {kind:'swap',seat,a,b} +export function opCandidates(state, seat, eff, ctx = {}) { + const p = P(state, seat); + const opps = aliveSeats(state).filter((s) => s !== seat); + switch (eff.op) { + case 'drawRoom': case 'drawSpell': case 'drawThenDiscard': case 'handReset': + case 'noBuildRound': case 'doubleTreasure': + case 'healWound': case 'pitTrap': case 'counterSpell': + return null; + case 'extraBuild': + if (eff.cond === 'fewerRooms' && !opps.some((s) => P(state, s).dungeon.length > p.dungeon.length)) return []; + return null; + case 'recoverDiscard': { + const pool = []; + if (eff.pool === 'any' || eff.pool === 'room') pool.push(...state.decks.roomDiscard); + if (eff.pool === 'monsterRoom') pool.push(...state.decks.roomDiscard.filter((c) => roomDef(c).type === 'monster')); + if (eff.pool === 'any' || eff.pool === 'spell') pool.push(...state.decks.spellDiscard); + return pool.map((c) => ({ kind: 'card', seat: -1, uid: c.uid })); + } + case 'revealTake': { + const out = []; + for (const s of opps) for (const c of handAll(P(state, s))) out.push({ kind: 'card', seat: s, uid: c.uid }); + return out; + } + case 'stealRandom': case 'opponentDiscardRandom': { + const filter = eff.filter || 'any'; + return opps.filter((s) => { + const o = P(state, s); + if (filter === 'spell') return o.hand.spells.length > 0; + if (filter === 'room') return o.hand.rooms.length > 0; + return handAll(o).length > 0; + }).map((s) => ({ kind: 'player', seat: s })); + } + case 'wreckEachOpponent': + return null; // queues per-opponent destroyOwnRoom decisions + case 'destroyOwnRoom': + return P(state, ctx.forSeat ?? seat).dungeon.map((_, i) => ({ kind: 'room', seat: ctx.forSeat ?? seat, slotIdx: i })); + case 'swapRooms': { + const out = []; + for (const s of aliveSeats(state)) { + const d = P(state, s).dungeon; + for (let a = 0; a < d.length; a++) for (let b = a + 1; b < d.length; b++) out.push({ kind: 'swap', seat: s, a, b }); + } + return out; + } + case 'deactivateRoom': { + const out = []; + for (const s of aliveSeats(state)) { + P(state, s).dungeon.forEach((slot, i) => { if (!slot.deactivated) out.push({ kind: 'room', seat: s, slotIdx: i }); }); + } + return out; + } + case 'tutorAdvanced': { + const seen = new Set(); + const out = []; + for (const c of [...state.decks.rooms, ...state.decks.roomDiscard]) { + const def = roomDef(c); + if (!def.advanced || def.type !== eff.roomType || seen.has(c.id)) continue; + seen.add(c.id); + for (let idx = 0; idx < p.dungeon.length; idx++) { + if (canBuildOver(state, seat, def, idx)) out.push({ kind: 'build', roomUid: c.uid, slotIdx: idx }); + } + } + return out; + } + case 'dmgBoostRoom': { + const out = []; + for (const s of aliveSeats(state)) { + P(state, s).dungeon.forEach((slot, i) => { + if (!slot.deactivated && roomDef(slot.room).type === eff.filter) out.push({ kind: 'room', seat: s, slotIdx: i }); + }); + } + return out; + } + case 'damageHero': case 'teleportHero': + return p.entrance.map((h) => ({ kind: 'hero', uid: h.uid })); + case 'healHero': { + const out = []; + for (const s of opps) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid }); + return out; + } + case 'fearHero': { + const out = []; + for (const s of aliveSeats(state)) for (const h of P(state, s).entrance) out.push({ kind: 'hero', uid: h.uid }); + return out; + } + case 'killHeroTown': + return state.town.map((h) => ({ kind: 'hero', uid: h.uid })); + case 'lureHero': { + const out = state.town + .filter((h) => eff.filter !== 'ordinary' || !heroDef(h).epic) + .map((h) => ({ kind: 'hero', uid: h.uid })); + if (eff.from === 'townOrDeck') { + for (const h of [...state.decks.heroes, ...state.decks.epics]) out.push({ kind: 'deckHero', uid: h.uid }); + } + return out; + } + case 'resurrectAttack': { + const out = []; + for (const s of opps) for (const h of P(state, s).soulCards) out.push({ kind: 'soul', seat: s, uid: h.uid }); + return out; + } + case 'blockHeroes': + return opps.filter((s) => P(state, s).souls >= p.souls + 2).map((s) => ({ kind: 'player', seat: s })); + case 'sacrificeSoulDraw': + return p.soulCards.map((h) => ({ kind: 'soul', seat, uid: h.uid })); + case 'caveIn': case 'rampTrap': case 'dmgBoostAllRooms': + // caveIn: own room to collapse. rampTrap/Crushinator: the *other* own room + // to demolish (the sacrifice), never the activating room itself. + return p.dungeon.map((_, i) => ({ kind: 'room', seat, slotIdx: i })) + .filter((r) => !((eff.op === 'rampTrap' || eff.op === 'dmgBoostAllRooms') && r.slotIdx === ctx.slotIdx)); + default: + return null; + } +} + +// ── Decisions ─────────────────────────────────────────────────────────────── +export function pendingDecision(state) { + if (state.gameOver) return null; + const q = state.effectQueue[0]; + if (q) { + if (q.kind === 'discard') return { kind: 'discard', seat: q.seat, cardType: q.cardType, n: q.n }; + if (q.kind === 'roomDraw') return { kind: 'roomDraw', seat: q.seat }; + return { kind: 'target', seat: q.seat, op: q.eff.op, candidates: q.candidates, optional: !!q.eff.optional, source: q.source }; + } + if (state.reaction) { + const r = state.reaction; + return { kind: 'react', seat: r.responders[r.idx], spellId: r.spellId, casterSeat: r.casterSeat, target: r.targetRef || null }; + } + if (state.window) { + const seat = nextWindowSeat(state); + if (seat != null) return { kind: 'window', seat, window: state.window.id, advSeat: state.window.advSeat }; + return null; // advance() will close it + } + if (state.phase === 'setupDiscard') { + const seat = aliveSeats(state).find((s) => !P(state, s).setupDiscarded); + if (seat != null) return { kind: 'setupDiscard', seat, n: 2 }; + } + if (state.phase === 'setupBuild' || state.phase === 'build') { + const seat = aliveSeats(state).find((s) => P(state, s).pendingBuild === undefined); + if (seat != null) return { kind: 'build', seat, setup: state.phase === 'setupBuild' }; + } + if (state.phase === 'extraBuild') { + const seat = aliveSeats(state).find((s) => P(state, s).extraBuilds > 0); + if (seat != null) return { kind: 'build', seat, extra: true }; + } + return null; +} + +function nextWindowSeat(state) { + const w = state.window; + if (w.casts >= WINDOW_CAST_CAP) return null; + for (const seat of aliveSeats(state)) { + if (w.passes.includes(seat)) continue; + const acts = windowActions(state, seat); + if (!acts.spells.length && !acts.rooms.length) { w.passes.push(seat); continue; } + return seat; + } + return null; +} + +// ── Mutators ──────────────────────────────────────────────────────────────── +function assertSeat(state, seat, kind) { + const d = pendingDecision(state); + if (!d || d.seat !== seat || d.kind !== kind) { + throw new Error(`illegal action: expected ${d ? `${d.kind} by seat ${d.seat}` : 'none'}, got ${kind} by ${seat}`); + } + return d; +} + +export function actSetupDiscard(state, seat, uids) { + assertSeat(state, seat, 'setupDiscard'); + if (!Array.isArray(uids) || uids.length !== 2) throw new Error('setupDiscard needs exactly 2 uids'); + const p = P(state, seat); + for (const uid of uids) { + const c = removeFromHand(p, uid); + if (!c) throw new Error('setupDiscard: card not in hand'); + discardCard(state, c); + } + p.setupDiscarded = true; + emit(state, { type: 'setupDiscard', seat }); + advance(state); +} + +export function actBuild(state, seat, choice) { + const d = assertSeat(state, seat, 'build'); + const p = P(state, seat); + if (choice) { + const legal = legalBuilds(state, seat).some((b) => b.roomUid === choice.roomUid && b.slotIdx === choice.slotIdx); + if (!legal) throw new Error('illegal build'); + } else if (d.setup && legalBuilds(state, seat).length) { + throw new Error('setup build is mandatory'); + } + if (d.extra) { + p.extraBuilds--; + if (choice && !state.roundNoBuild) placeRoom(state, seat, choice.roomUid, choice.slotIdx); + } else { + p.pendingBuild = choice || null; + emit(state, { type: 'buildSubmitted', seat, pass: !choice }); + } + advance(state); +} + +export function actWindow(state, seat, action) { + assertSeat(state, seat, 'window'); + const w = state.window; + if (!action || action.pass) { + w.passes.push(seat); + emit(state, { type: 'windowPass', seat }); + advance(state); + return; + } + w.casts++; + w.passes = []; // an action reopens responses for everyone + if (action.spellUid != null) { + castSpell(state, seat, action.spellUid, action.target); + } else { + activateRoom(state, seat, action.slotIdx, action.effIdx, action); + } + advance(state); +} + +export function actReact(state, seat, response) { + assertSeat(state, seat, 'react'); + const r = state.reaction; + if (!response) { + r.idx++; + if (r.idx >= r.responders.length) resolveReaction(state, false); + } else { + const p = P(state, seat); + if (response.type === 'counterspell') { + const inst = p.hand.spells.find((c) => c.uid === response.spellUid && spellDef(c).op.op === 'counterSpell'); + if (!inst) throw new Error('no counterspell in hand'); + removeFromHand(p, inst.uid); + discardCard(state, inst); + } else if (response.type === 'allseeingeye') { + const slot = p.dungeon[response.slotIdx]; + const def = slot && roomDef(slot.room); + const effIdx = def && (def.effects || []).findIndex((e) => e.trigger === 'reaction'); + if (!slot || slot.deactivated || effIdx < 0 || slot.usedOnce[effIdx]) throw new Error('cannot react with that room'); + const cost = p.hand.spells.find((c) => c.uid === response.discardSpellUid); + if (!cost) throw new Error('reaction needs a spell to discard'); + removeFromHand(p, cost.uid); + discardCard(state, cost); + slot.usedOnce[effIdx] = true; + } else { + throw new Error('unknown reaction'); + } + emit(state, { type: 'spellCountered', seat, casterSeat: r.casterSeat, spellId: r.spellId }); + resolveReaction(state, true); + } + advance(state); +} + +export function actChooseTarget(state, seat, targetRef) { + const d = assertSeat(state, seat, 'target'); + const q = state.effectQueue[0]; + if (!targetRef) { + if (!d.optional) throw new Error('target is required'); + state.effectQueue.shift(); + emit(state, { type: 'fizzle', seat, op: q.eff.op }); + advance(state); + return; + } + const match = q.candidates.some((c) => sameRef(c, targetRef)); + if (!match) throw new Error('target not in candidates'); + state.effectQueue.shift(); + applyOp(state, seat, q.eff, targetRef, q.ctx || {}); + advance(state); +} + +export function actDiscard(state, seat, uids) { + const d = assertSeat(state, seat, 'discard'); + if (!Array.isArray(uids) || uids.length !== d.n) throw new Error(`discard needs exactly ${d.n} uids`); + const p = P(state, seat); + for (const uid of uids) { + const pool = d.cardType === 'spell' ? p.hand.spells : p.hand.rooms; + if (!pool.some((c) => c.uid === uid)) throw new Error('discard: card not in hand'); + const c = removeFromHand(p, uid); + discardCard(state, c); + emit(state, { type: 'discard', seat, id: c.id }); + } + state.effectQueue.shift(); + advance(state); +} + +export function actRoomDraw(state, seat, pick) { + assertSeat(state, seat, 'roomDraw'); + state.effectQueue.shift(); + if (pick === 'spell') drawSpells(state, seat, 1); + else drawRooms(state, seat, 1); + advance(state); +} + +function sameRef(a, b) { + return a.kind === b.kind && a.uid === b.uid && a.seat === b.seat + && a.slotIdx === b.slotIdx && a.roomUid === b.roomUid && a.a === b.a && a.b === b.b; +} + +// ── Spells & activated rooms ──────────────────────────────────────────────── +function castSpell(state, seat, spellUid, targetRef) { + const p = P(state, seat); + const inst = p.hand.spells.find((c) => c.uid === spellUid); + if (!inst) throw new Error('spell not in hand'); + const def = spellDef(inst); + if (!spellPhaseOk(def, state.window.id)) throw new Error('wrong phase for spell'); + const cands = opCandidates(state, seat, def.op, { source: 'spell' }); + if (cands !== null) { + if (!targetRef || !cands.some((c) => sameRef(c, targetRef))) throw new Error('bad spell target'); + } + removeFromHand(p, inst.uid); + emit(state, { type: 'spellCast', seat, id: inst.id, target: targetRef || null }); + // Liger's Den: once a round, when you cast a spell, draw a spell. + p.dungeon.forEach((slot) => { + if (slot.deactivated) return; + (roomDef(slot.room).effects || []).forEach((eff, e) => { + if (eff.trigger === 'onCastSpell' && !slot.usedOnce[e]) { slot.usedOnce[e] = true; drawSpells(state, seat, eff.n); } + }); + }); + const responders = reactionResponders(state, seat); + if (responders.length) { + state.reaction = { casterSeat: seat, spellUid: inst.uid, spellId: inst.id, inst, eff: def.op, targetRef, responders, idx: 0 }; + } else { + discardCard(state, inst); + applyOp(state, seat, def.op, targetRef, { source: 'spell' }); + } +} + +function reactionResponders(state, casterSeat) { + const out = []; + for (const seat of aliveSeats(state)) { + if (seat === casterSeat) continue; + const p = P(state, seat); + const hasCounter = p.hand.spells.some((c) => spellDef(c).op.op === 'counterSpell'); + const hasEye = p.dungeon.some((slot) => { + if (slot.deactivated) return false; + return (roomDef(slot.room).effects || []).some((e, i) => e.trigger === 'reaction' && !slot.usedOnce[i]); + }) && p.hand.spells.length > 0; + if (hasCounter || hasEye) out.push(seat); + } + return out; +} + +function resolveReaction(state, countered) { + const r = state.reaction; + state.reaction = null; + discardCard(state, r.inst); + if (!countered) applyOp(state, r.casterSeat, r.eff, r.targetRef, { source: 'spell' }); +} + +function activateRoom(state, seat, slotIdx, effIdx, action) { + const p = P(state, seat); + const slot = p.dungeon[slotIdx]; + if (!slot || slot.deactivated || slot.armed) throw new Error('cannot activate that room'); + const def = roomDef(slot.room); + const eff = (def.effects || [])[effIdx]; + if (!eff || eff.trigger !== 'activated') throw new Error('no such activated ability'); + if (!windowKindOk(eff.window, state.window.id)) throw new Error('wrong window'); + if (eff.oncePerTurn && slot.usedOnce[effIdx]) throw new Error('already used this round'); + if (!costPayable(state, seat, eff.cost, slotIdx)) throw new Error('cannot pay cost'); + const cands = opCandidates(state, seat, eff, { source: 'room', slotIdx }); + if (cands !== null && (!action.target || !cands.some((c) => sameRef(c, action.target)))) throw new Error('bad ability target'); + + emit(state, { type: 'roomActivated', seat, slotIdx, id: slot.room.id }); + if (eff.oncePerTurn) slot.usedOnce[effIdx] = true; + payCost(state, seat, eff.cost, slotIdx, action); + applyOp(state, seat, eff, action.target, { source: 'room', slotIdx, slot }); + // destroySelf sacrifices resolve after the effect so index-based targets stay + // valid. pitTrap keeps its room armed instead; it is destroyed on trigger (or + // at end of round if no hero ever enters). + if (eff.cost && eff.cost.destroySelf && eff.op !== 'pitTrap') destroySlotByRef(state, seat, slot); +} + +function payCost(state, seat, cost, slotIdx, action = {}) { + if (!cost) return; + const p = P(state, seat); + if (cost.discardRooms) { + const uids = action.costUids || p.hand.rooms.slice(0, cost.discardRooms).map((c) => c.uid); + if (uids.length !== cost.discardRooms) throw new Error('cost: wrong discard count'); + for (const uid of uids) { + const c = p.hand.rooms.find((x) => x.uid === uid); + if (!c) throw new Error('cost: room not in hand'); + removeFromHand(p, uid); discardCard(state, c); + } + } + if (cost.discardMonsterRoom) { + const uid = action.costUids ? action.costUids[0] : (p.hand.rooms.find((c) => roomDef(c).type === 'monster') || {}).uid; + const c = p.hand.rooms.find((x) => x.uid === uid && roomDef(x).type === 'monster'); + if (!c) throw new Error('cost: no monster room to discard'); + removeFromHand(p, uid); discardCard(state, c); + } + if (cost.discardSpell) { + const uid = action.costUids ? action.costUids[0] : (p.hand.spells[0] || {}).uid; + const c = p.hand.spells.find((x) => x.uid === uid); + if (!c) throw new Error('cost: no spell to discard'); + removeFromHand(p, uid); discardCard(state, c); + } + // destroySelf / destroyOther are handled inside the op (pitTrap, rampTrap, + // darkaltar etc.) so ordering with the effect is explicit. +} + +// ── Effect application ────────────────────────────────────────────────────── +function queueEffect(state, seat, eff, ctx = {}) { + const candidates = opCandidates(state, seat, eff, ctx); + if (candidates === null) { applyOp(state, seat, eff, null, ctx); return; } + if (!candidates.length) { emit(state, { type: 'fizzle', seat, op: eff.op }); return; } + if (eff.auto) { applyOp(state, seat, eff, autoPick(state, eff, candidates), ctx); return; } + state.effectQueue.push({ kind: 'target', seat, eff, candidates, ctx, source: ctx.source || null }); +} + +// Deterministic auto-pick for effects flagged `auto` (Open Grave): best room by damage. +function autoPick(state, eff, candidates) { + if (eff.op === 'recoverDiscard') { + let best = candidates[0]; let bestDmg = -1; + for (const c of candidates) { + const inst = state.decks.roomDiscard.find((x) => x.uid === c.uid); + const d = inst ? roomDef(inst).dmg : -1; + if (d > bestDmg) { bestDmg = d; best = c; } + } + return best; + } + return candidates[0]; +} + +function applyOp(state, seat, eff, targetRef, ctx = {}) { + const p = P(state, seat); + switch (eff.op) { + case 'drawRoom': drawRooms(state, seat, eff.n || 1); break; + case 'drawSpell': drawSpells(state, seat, eff.n || 1); break; + case 'drawThenDiscard': + if (eff.draw === 'spell') drawSpells(state, seat, eff.n); else drawRooms(state, seat, eff.n); + if ((eff.discard === 'spell' ? p.hand.spells : p.hand.rooms).length >= eff.d) { + state.effectQueue.push({ kind: 'discard', seat, cardType: eff.discard, n: eff.d }); + } + break; + case 'recoverDiscard': { + let inst = null; + let i = state.decks.roomDiscard.findIndex((c) => c.uid === targetRef.uid); + if (i >= 0) { inst = state.decks.roomDiscard.splice(i, 1)[0]; p.hand.rooms.push(inst); } + else { + i = state.decks.spellDiscard.findIndex((c) => c.uid === targetRef.uid); + if (i >= 0) { inst = state.decks.spellDiscard.splice(i, 1)[0]; p.hand.spells.push(inst); } + } + if (inst) emit(state, { type: 'recover', seat, id: inst.id }); + break; + } + case 'revealTake': { + const o = P(state, targetRef.seat); + const c = removeFromHand(o, targetRef.uid); + if (c) { + (ROOMS[c.id] ? p.hand.rooms : p.hand.spells).push(c); + emit(state, { type: 'cardTaken', seat, from: targetRef.seat, id: c.id }); + } + break; + } + case 'stealRandom': case 'opponentDiscardRandom': { + const o = P(state, targetRef.seat); + const filter = eff.filter || 'any'; + const pool = filter === 'spell' ? o.hand.spells : filter === 'room' ? o.hand.rooms : handAll(o); + if (!pool.length) break; + const c = state.rng.pick(pool); + removeFromHand(o, c.uid); + if (eff.op === 'stealRandom') { + (ROOMS[c.id] ? p.hand.rooms : p.hand.spells).push(c); + emit(state, { type: 'cardTaken', seat, from: targetRef.seat, id: c.id }); + } else { + discardCard(state, c); + emit(state, { type: 'discard', seat: targetRef.seat, id: c.id, forced: true }); + } + break; + } + case 'handReset': + for (const s of aliveSeats(state)) { + const o = P(state, s); + for (const c of handAll(o)) discardCard(state, c); + o.hand.rooms = []; o.hand.spells = []; + drawSpells(state, s, 1); drawRooms(state, s, 2); + } + emit(state, { type: 'handReset' }); + break; + case 'wreckEachOpponent': + for (const s of state.turnOrder) { + if (s === seat || !P(state, s).alive || !P(state, s).dungeon.length) continue; + state.effectQueue.push({ + kind: 'target', seat: s, eff: { op: 'destroyOwnRoom' }, + candidates: opCandidates(state, s, { op: 'destroyOwnRoom' }), ctx: {}, + }); + } + break; + case 'destroyOwnRoom': + destroySlot(state, targetRef.seat, targetRef.slotIdx); + break; + case 'swapRooms': { + const d = P(state, targetRef.seat).dungeon; + [d[targetRef.a], d[targetRef.b]] = [d[targetRef.b], d[targetRef.a]]; + emit(state, { type: 'roomsSwapped', seat: targetRef.seat, a: targetRef.a, b: targetRef.b }); + break; + } + case 'extraBuild': + p.extraBuilds++; + break; + case 'noBuildRound': + state.roundNoBuild = true; + emit(state, { type: 'noBuild' }); + break; + case 'deactivateRoom': { + const slot = P(state, targetRef.seat).dungeon[targetRef.slotIdx]; + slot.deactivated = true; + emit(state, { type: 'roomFrozen', seat: targetRef.seat, slotIdx: targetRef.slotIdx }); + break; + } + case 'tutorAdvanced': { + let i = state.decks.rooms.findIndex((c) => c.uid === targetRef.roomUid); + let inst; + if (i >= 0) inst = state.decks.rooms.splice(i, 1)[0]; + else { + i = state.decks.roomDiscard.findIndex((c) => c.uid === targetRef.roomUid); + inst = state.decks.roomDiscard.splice(i, 1)[0]; + } + p.hand.rooms.push(inst); + placeRoom(state, seat, inst.uid, targetRef.slotIdx); + break; + } + case 'dmgBoostRoom': { + const slot = P(state, targetRef.seat).dungeon[targetRef.slotIdx]; + slot.tempDmg += eff.amount; + emit(state, { type: 'roomBoosted', seat: targetRef.seat, slotIdx: targetRef.slotIdx, amount: eff.amount }); + break; + } + case 'dmgBoostAllRooms': + destroySlot(state, seat, targetRef.slotIdx); // the sacrificed room + p.roundMods.allBoost += eff.amount; + emit(state, { type: 'allRoomsBoosted', seat, amount: eff.amount }); + break; + case 'doubleTreasure': + p.roundMods.doubleTreasure = true; + emit(state, { type: 'treasureDoubled', seat }); + break; + case 'damageHero': { + const found = findEntranceHero(state, targetRef.uid); + if (!found) break; + const amount = eff.amount === 'dungeonSize' ? p.dungeon.length : eff.amount; + found.hero.hp -= amount; + emit(state, { type: 'heroHurt', uid: found.hero.uid, amount, seat: found.seat }); + if (found.hero.hp <= 0) scoreHeroKill(state, seat, found.hero, { removeFromSeat: found.seat, noRoom: true }); + break; + } + case 'healHero': { + const found = findEntranceHero(state, targetRef.uid); + if (found) { found.hero.hp += eff.amount; emit(state, { type: 'heroHealed', uid: found.hero.uid, amount: eff.amount }); } + break; + } + case 'fearHero': { + const found = findEntranceHero(state, targetRef.uid); + if (found) { + const q = P(state, found.seat).entrance; + q.splice(q.findIndex((h) => h.uid === found.hero.uid), 1); + state.town.push(found.hero); + emit(state, { type: 'heroFeared', uid: found.hero.uid, from: found.seat }); + } + break; + } + case 'teleportHero': { + const found = findEntranceHero(state, targetRef.uid); + if (found) { found.hero.teleport = true; emit(state, { type: 'heroTeleportMarked', uid: found.hero.uid }); } + break; + } + case 'killHeroTown': { + const i = state.town.findIndex((h) => h.uid === targetRef.uid); + if (i >= 0) { + const hero = state.town.splice(i, 1)[0]; + scoreHeroKill(state, seat, hero, { noRoom: true }); + } + break; + } + case 'lureHero': { + let hero = null; + let i = state.town.findIndex((h) => h.uid === targetRef.uid); + if (i >= 0) hero = state.town.splice(i, 1)[0]; + else { + for (const deck of [state.decks.heroes, state.decks.epics]) { + i = deck.findIndex((h) => h.uid === targetRef.uid); + if (i >= 0) { hero = deck.splice(i, 1)[0]; break; } + } + } + if (hero) { p.entrance.push(hero); emit(state, { type: 'heroLured', seat, uid: hero.uid, id: hero.id }); } + break; + } + case 'resurrectAttack': { + const o = P(state, targetRef.seat); + const i = o.soulCards.findIndex((h) => h.uid === targetRef.uid); + if (i < 0) break; + const hero = o.soulCards.splice(i, 1)[0]; + o.souls -= heroSouls(heroDef(hero)); + hero.hp = hero.hpMax + (eff.hpBonus || 0); + o.entrance.push(hero); + emit(state, { type: 'heroResurrected', seat: targetRef.seat, uid: hero.uid, id: hero.id }); + break; + } + case 'blockHeroes': + P(state, targetRef.seat).roundMods.blocked = true; + emit(state, { type: 'heroesBlocked', seat: targetRef.seat }); + break; + case 'sacrificeSoulDraw': { + const i = p.soulCards.findIndex((h) => h.uid === targetRef.uid); + if (i < 0) break; + const hero = p.soulCards.splice(i, 1)[0]; + p.souls -= heroSouls(heroDef(hero)); + drawSpells(state, seat, eff.n); + emit(state, { type: 'soulBurned', seat, id: hero.id }); + break; + } + case 'healWound': + // Tabletop flips the wound into a soul; we simply heal it. + if (p.wounds > 0) { p.wounds--; emit(state, { type: 'woundHealed', seat }); } + break; + case 'pitTrap': { + const slot = ctx.slot; + slot.armed = { type: 'pit' }; + emit(state, { type: 'trapArmed', seat, kind: 'pit' }); + break; + } + case 'rampTrap': { + destroySlot(state, seat, targetRef.slotIdx); + const slot = ctx.slot; // ramp itself (survives; the target was the sacrifice) + if (P(state, seat).dungeon.includes(slot)) { + slot.armed = { type: 'ramp', bonus: 5 }; + emit(state, { type: 'trapArmed', seat, kind: 'ramp' }); + } + break; + } + case 'caveIn': { + const slot = P(state, seat).dungeon[targetRef.slotIdx]; + if (slot) { slot.armed = { type: 'cavein' }; emit(state, { type: 'trapArmed', seat, kind: 'cavein' }); } + break; + } + case 'counterSpell': + break; // handled in the reaction flow + default: + throw new Error(`unknown op ${eff.op}`); + } +} + +function findEntranceHero(state, uid) { + for (const seat of aliveSeats(state)) { + const hero = P(state, seat).entrance.find((h) => h.uid === uid); + if (hero) return { seat, hero }; + } + return null; +} + +function destroySlotByRef(state, seat, slot) { + if (!slot) return; + const idx = P(state, seat).dungeon.indexOf(slot); + if (idx >= 0) destroySlot(state, seat, idx); +} + +function destroySlot(state, seat, slotIdx) { + const p = P(state, seat); + const slot = p.dungeon[slotIdx]; + if (!slot) return; + p.dungeon.splice(slotIdx, 1); + discardCard(state, slot.room); + for (const c of slot.under) discardCard(state, c); + emit(state, { type: 'roomDestroyed', seat, slotIdx, id: slot.room.id }); + // Recycling Center: whenever ANOTHER of your rooms is destroyed, draw 2 rooms. + for (const s of p.dungeon) { + if (s.deactivated) continue; + (roomDef(s.room).effects || []).forEach((eff) => { + if (eff.trigger === 'onOwnRoomDestroyed') drawRooms(state, seat, eff.n); + }); + } +} + +// ── Build resolution ──────────────────────────────────────────────────────── +function placeRoom(state, seat, roomUid, slotIdx, { setup = false } = {}) { + const p = P(state, seat); + const inst = p.hand.rooms.find((c) => c.uid === roomUid); + removeFromHand(p, roomUid); + const def = roomDef(inst); + if (slotIdx >= p.dungeon.length) { + p.dungeon.push(newSlot(inst)); + } else { + const slot = p.dungeon[slotIdx]; + slot.under.push(slot.room); + slot.room = inst; + slot.deactivated = false; + slot.usedOnce = {}; + slot.tempDmg = 0; + slot.armed = null; + } + emit(state, { type: 'roomBuilt', seat, slotIdx: Math.min(slotIdx, p.dungeon.length - 1), id: inst.id, advanced: !!def.advanced }); + // Own Beast Menagerie: once a round, when you build another monster room, draw. + if (def.type === 'monster') { + p.dungeon.forEach((slot) => { + if (slot.deactivated || slot.room.uid === inst.uid) return; + (roomDef(slot.room).effects || []).forEach((eff, e) => { + if (eff.trigger === 'onBuildOtherMonster' && !slot.usedOnce[e]) { slot.usedOnce[e] = true; drawRooms(state, seat, eff.n); } + }); + }); + } + // onBuild effects of the room itself (suppressed for the simultaneous setup + // build so round 1 starts from a clean slate). + if (!setup) { + for (const eff of (def.effects || [])) { + if (eff.trigger === 'onBuild') queueEffect(state, seat, eff, { source: 'room', slotIdx }); + } + } + // Level up on reaching the 5-room maximum, once per game. + if (p.dungeon.length === MAX_ROOMS && !p.boss.leveledUp) { + p.boss.leveledUp = true; + emit(state, { type: 'levelUp', seat, boss: p.boss.id }); + queueEffect(state, seat, { ...BOSSES[p.boss.id].levelUp, optional: true }, { source: 'levelUp' }); + } +} + +function flipBuilds(state, { setup = false } = {}) { + for (const seat of state.turnOrder) { + const p = P(state, seat); + if (!p.alive) continue; + const b = p.pendingBuild; + p.pendingBuild = undefined; + if (!b) { emit(state, { type: 'flip', seat, pass: true }); continue; } + if (state.roundNoBuild) { emit(state, { type: 'flip', seat, cancelled: true }); continue; } // card stays in hand + // Re-validate: an earlier flip/effect may have invalidated the placement. + const stillLegal = legalBuilds(state, seat).some((x) => x.roomUid === b.roomUid && x.slotIdx === b.slotIdx); + if (!stillLegal) { emit(state, { type: 'flip', seat, cancelled: true }); continue; } + emit(state, { type: 'flip', seat }); + placeRoom(state, seat, b.roomUid, b.slotIdx, { setup }); + } +} + +// ── Bait ──────────────────────────────────────────────────────────────────── +export function baitTargets(state) { + const out = {}; + for (const hero of state.town) { + const def = heroDef(hero); + const eligible = aliveSeats(state).filter((s) => !P(state, s).roundMods.blocked); + let best = null; + if (def.fool) { + // The Fool storms the dungeon of the player with the FEWEST souls. + let min = Infinity; let ties = 0; + for (const s of eligible) { + const v = P(state, s).souls; + if (v < min) { min = v; best = s; ties = 1; } else if (v === min) ties++; + } + if (ties > 1) best = null; + } else { + let max = 0; let ties = 0; + for (const s of eligible) { + const v = treasureCount(state, s, def.cls); + if (v > max) { max = v; best = s; ties = 1; } else if (v === max && v > 0) ties++; + } + if (ties > 1) best = null; + } + out[hero.uid] = best; + } + return out; +} + +function runBait(state) { + const targets = baitTargets(state); + const staying = []; + for (const hero of state.town) { + const seat = targets[hero.uid]; + if (seat == null) { staying.push(hero); continue; } + P(state, seat).entrance.push(hero); + emit(state, { type: 'heroWalks', uid: hero.uid, id: hero.id, seat }); + } + state.town = staying; +} + +// ── Adventure ─────────────────────────────────────────────────────────────── +function startAdventure(state) { + state.phase = 'adventure'; + state.adv = { orderIdx: -1, walking: null, windowOpened: false }; + nextAdventureSeat(state); +} + +function nextAdventureSeat(state) { + if (state.gameOver || !state.adv) return; + const a = state.adv; + a.walking = null; + a.windowOpened = false; + while (true) { + a.orderIdx++; + if (a.orderIdx >= state.turnOrder.length) { state.adv = null; endRound(state); return; } + const seat = state.turnOrder[a.orderIdx]; + if (P(state, seat).alive && P(state, seat).entrance.length) { + state.window = { id: 'advStart', advSeat: seat, passes: [], casts: 0 }; + a.windowOpened = true; + emit(state, { type: 'adventureStart', seat }); + return; + } + } +} + +// Walk heroes one room-step at a time; pauses whenever effectQueue gains a decision. +function stepAdventure(state) { + const a = state.adv; + const seat = state.turnOrder[a.orderIdx]; + const p = P(state, seat); + while (true) { + if (state.effectQueue.length || state.gameOver) return; + if (!a.walking) { + if (!p.entrance.length || !p.alive) { nextAdventureSeat(state); return; } + const hero = p.entrance.shift(); + a.walking = { hero, roomIdx: p.dungeon.length - 1, bounced: false, secondPass: false }; + emit(state, { type: 'heroEnters', seat, uid: hero.uid, id: hero.id }); + } + const w = a.walking; + const hero = w.hero; + if (w.roomIdx < 0) { + if (hero.teleport && !w.secondPass) { + hero.teleport = false; + w.roomIdx = p.dungeon.length - 1; + w.secondPass = true; + w.bounced = false; + emit(state, { type: 'heroTeleported', seat, uid: hero.uid }); + continue; + } + // Survived: the boss takes wounds. + const wounds = heroWounds(heroDef(hero)); + p.wounds += wounds; + emit(state, { type: 'bossWounded', seat, uid: hero.uid, id: hero.id, wounds, total: p.wounds }); + a.walking = null; + if (p.wounds >= WOUNDS_TO_DIE) { eliminate(state, seat); nextAdventureSeat(state); return; } + continue; + } + const slot = p.dungeon[w.roomIdx]; + if (!slot) { w.roomIdx = Math.min(w.roomIdx - 1, p.dungeon.length - 1); continue; } + if (slot.deactivated) { w.roomIdx--; continue; } + const def = roomDef(slot.room); + // Armed traps resolve on entry. + if (slot.armed) { + const armed = slot.armed; + slot.armed = null; + if (armed.type === 'pit' || armed.type === 'cavein') { + const idx = p.dungeon.indexOf(slot); + destroySlot(state, seat, idx); + scoreHeroKill(state, seat, hero, { slot: null }); + a.walking = null; + continue; + } + if (armed.type === 'ramp') { + hero.hp -= armed.bonus; + emit(state, { type: 'heroHurt', uid: hero.uid, amount: armed.bonus, seat }); + if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; } + } + } + // Minotaur's Maze: the first hero through each round re-enters the room it + // just left (takes its damage again), then continues. + if (def.passive === 'minotaurBounce' && !slot.usedOnce.bounce && !w.bounced && w.roomIdx < p.dungeon.length - 1) { + slot.usedOnce.bounce = true; + w.bounced = true; + const backIdx = w.roomIdx + 1; + const dmg = roomDamage(state, seat, backIdx); + if (dmg > 0) { + hero.hp -= dmg; + emit(state, { type: 'roomHits', seat, slotIdx: backIdx, uid: hero.uid, amount: dmg, bounce: true }); + if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot: p.dungeon[backIdx] }); a.walking = null; continue; } + } + } + const dmg = roomDamage(state, seat, w.roomIdx); + if (dmg > 0) { + hero.hp -= dmg; + emit(state, { type: 'roomHits', seat, slotIdx: w.roomIdx, uid: hero.uid, amount: dmg }); + if (hero.hp <= 0) { scoreHeroKill(state, seat, hero, { slot }); a.walking = null; continue; } + } + w.roomIdx--; + } +} + +function scoreHeroKill(state, seat, hero, { slot = null, removeFromSeat = null, noRoom = false } = {}) { + if (removeFromSeat != null) { + const q = P(state, removeFromSeat).entrance; + const i = q.findIndex((h) => h.uid === hero.uid); + if (i >= 0) q.splice(i, 1); + } + const p = P(state, seat); + const def = heroDef(hero); + p.souls += heroSouls(def); + p.soulCards.push(hero); + emit(state, { type: 'heroDies', seat, uid: hero.uid, id: hero.id, souls: heroSouls(def), total: p.souls }); + if (!noRoom && slot && !slot.deactivated) { + const rdef = roomDef(slot.room); + (rdef.effects || []).forEach((eff, e) => { + if (eff.trigger !== 'onHeroDieHere' || slot.usedOnce[e]) return; + slot.usedOnce[e] = true; + queueEffect(state, seat, eff, { source: 'room' }); + }); + } +} + +function eliminate(state, seat) { + const p = P(state, seat); + p.alive = false; + for (const c of handAll(p)) discardCard(state, c); + p.hand.rooms = []; p.hand.spells = []; + for (const slot of p.dungeon) { discardCard(state, slot.room); for (const c of slot.under) discardCard(state, c); } + p.dungeon = []; + p.entrance = []; + p.pendingBuild = undefined; + p.extraBuilds = 0; + emit(state, { type: 'eliminated', seat }); + state.effectQueue = state.effectQueue.filter((q) => q.seat !== seat); + const alive = aliveSeats(state); + if (alive.length === 1) endGame(state, alive[0], 'lastStanding'); +} + +// ── Round flow ────────────────────────────────────────────────────────────── +function startRound(state) { + state.round++; + state.roundNoBuild = false; + for (const seat of aliveSeats(state)) { + const p = P(state, seat); + p.roundMods = freshMods(); + p.extraBuilds = 0; + for (const slot of p.dungeon) { + slot.deactivated = false; + slot.usedOnce = {}; + slot.tempDmg = 0; + if (slot.armed) slot.armed = null; + } + } + emit(state, { type: 'roundStart', round: state.round }); + // Reveal one hero per (living) player. + let exhausted = false; + for (let i = 0; i < aliveSeats(state).length; i++) { + let deck = state.decks.heroes; + if (!deck.length) { state.epicsActive = true; deck = state.decks.epics; } + if (!deck.length) { exhausted = true; break; } + const hero = deck.pop(); + state.town.push(hero); + emit(state, { type: 'heroRevealed', uid: hero.uid, id: hero.id, epic: heroDef(hero).epic }); + } + if (exhausted && !state.town.length) { + // Decks empty and nobody left to fight: highest souls wins now. + endGame(state, bestBySouls(state), 'decksExhausted'); + return; + } + // Draws — Haunted Library owners choose room-or-spell. + for (const seat of aliveSeats(state)) { + const p = P(state, seat); + const hasLibrary = p.dungeon.some((s) => !s.deactivated && roomDef(s.room).passive === 'librarySpellDraw'); + if (hasLibrary) state.effectQueue.push({ kind: 'roomDraw', seat }); + else drawRooms(state, seat, 1); + } + state.phase = 'roundStart'; +} + +function endRound(state) { + if (state.gameOver) return; + // Untriggered armed pits/cave-ins still collapse at end of round. + for (const seat of aliveSeats(state)) { + const p = P(state, seat); + for (let i = p.dungeon.length - 1; i >= 0; i--) { + const armed = p.dungeon[i].armed; + if (armed && (armed.type === 'pit' || armed.type === 'cavein')) destroySlot(state, seat, i); + } + } + const winners = aliveSeats(state).filter((s) => P(state, s).souls >= SOULS_TO_WIN); + if (winners.length) { + // Most souls; ties go to the LOWEST-XP boss. + winners.sort((a, b) => (P(state, b).souls - P(state, a).souls) + || (BOSSES[P(state, a).boss.id].xp - BOSSES[P(state, b).boss.id].xp)); + endGame(state, winners[0], 'souls'); + return; + } + emit(state, { type: 'roundEnd', round: state.round }); + startRound(state); +} + +function bestBySouls(state) { + const alive = aliveSeats(state); + alive.sort((a, b) => (P(state, b).souls - P(state, a).souls) + || (BOSSES[P(state, a).boss.id].xp - BOSSES[P(state, b).boss.id].xp)); + return alive[0]; +} + +function endGame(state, winner, reason) { + state.gameOver = true; + state.winner = winner; + state.phase = 'gameover'; + state.window = null; + state.reaction = null; + state.effectQueue = []; + state.adv = null; + emit(state, { type: 'gameOver', winner, reason }); +} + +export function isOver(state) { return state.gameOver; } + +export function finalRanking(state) { + return state.players.slice() + .sort((a, b) => (b.alive - a.alive) || (b.souls - a.souls) + || (BOSSES[a.boss.id].xp - BOSSES[b.boss.id].xp)) + .map((p) => p.seat); +} + +// ── The advance loop ──────────────────────────────────────────────────────── +function advance(state) { + let guard = 0; + while (!state.gameOver) { + if (++guard > 10000) throw new Error('advance() ran away'); + if (state.effectQueue.length || state.reaction) return; // decision needed + if (state.window) { + const seat = nextWindowSeat(state); + if (seat != null) return; + const closed = state.window; + state.window = null; + if (closed.id === 'buildStart') { state.phase = 'build'; continue; } + if (closed.id === 'postFlip') { runBait(state); startAdventure(state); continue; } + if (closed.id === 'advStart') { stepAdventure(state); continue; } + } + if (state.adv && state.adv.windowOpened && !state.window) { + // resumed mid-walk after an effect decision + stepAdventure(state); + if (state.effectQueue.length || state.window || state.gameOver) continue; + if (!state.adv) continue; + return; // shouldn't happen; stepAdventure always progresses + } + switch (state.phase) { + case 'setupDiscard': + if (aliveSeats(state).every((s) => P(state, s).setupDiscarded)) { state.phase = 'setupBuild'; continue; } + return; + case 'setupBuild': + if (aliveSeats(state).every((s) => P(state, s).pendingBuild !== undefined)) { + flipBuilds(state, { setup: true }); + state.setupBuilt = true; + startRound(state); + continue; + } + return; + case 'roundStart': + state.window = { id: 'buildStart', passes: [], casts: 0 }; + state.phase = 'windowBuildStart'; + continue; + case 'windowBuildStart': + // window handled above; when closed phase becomes 'build' + if (!state.window) { state.phase = 'build'; continue; } + return; + case 'build': + if (aliveSeats(state).every((s) => P(state, s).pendingBuild !== undefined)) { + flipBuilds(state); + state.phase = 'extraBuild'; + continue; + } + return; + case 'extraBuild': + if (state.roundNoBuild) { for (const s of aliveSeats(state)) P(state, s).extraBuilds = 0; } + if (aliveSeats(state).some((s) => P(state, s).extraBuilds > 0)) return; + state.window = { id: 'postFlip', passes: [], casts: 0 }; + state.phase = 'windowPostFlip'; + continue; + case 'windowPostFlip': + if (!state.window) { runBait(state); startAdventure(state); continue; } + return; + case 'adventure': + if (!state.window && state.adv) { stepAdventure(state); continue; } + return; + default: + return; + } + } +} + +// ── Public (redacted) view for the AI ─────────────────────────────────────── +// Own hand stays visible; opponents' hands and pending builds are hidden. +export function publicView(state, seat) { + return { + seed: state.seed, nPlayers: state.nPlayers, round: state.round, + phase: state.phase, window: state.window, turnOrder: state.turnOrder, + epicsActive: state.epicsActive, + // Deck contents are hidden (stubs keep lengths honest); discards are public. + decks: { + rooms: state.decks.rooms.map(() => ({ uid: -1, id: null, hidden: true })), + spells: state.decks.spells.map(() => ({ uid: -1, id: null, hidden: true })), + heroes: state.decks.heroes.map(() => ({ uid: -1, id: null, hidden: true })), + epics: state.decks.epics.map(() => ({ uid: -1, id: null, hidden: true })), + roomDiscard: state.decks.roomDiscard, + spellDiscard: state.decks.spellDiscard, + }, + roomDiscard: state.decks.roomDiscard, spellDiscard: state.decks.spellDiscard, + town: state.town, + players: state.players.map((p) => (p.seat === seat ? p : { + seat: p.seat, alive: p.alive, boss: p.boss, dungeon: p.dungeon, + // Hidden cards become stubs so shared helpers can still count them. + hand: { + rooms: p.hand.rooms.map(() => ({ uid: -1, id: null, hidden: true })), + spells: p.hand.spells.map(() => ({ uid: -1, id: null, hidden: true })), + }, + entrance: p.entrance, souls: p.souls, soulCards: p.soulCards, + wounds: p.wounds, pendingBuild: p.pendingBuild === undefined ? undefined : p.pendingBuild !== null, + extraBuilds: p.extraBuilds, roundMods: p.roundMods, + })), + gameOver: state.gameOver, winner: state.winner, + }; +} diff --git a/src/games/dungeonboss/sprites.md b/src/games/dungeonboss/sprites.md new file mode 100644 index 0000000..9b9c11f --- /dev/null +++ b/src/games/dungeonboss/sprites.md @@ -0,0 +1,252 @@ +# Dungeon Boss — Sprite / Art Spec + +Everything in Dungeon Boss renders procedurally out of the box (frame, border, +class-colored accents, name, rules text, treasure icons), so the game is fully +playable with **no** art. This document lists the optional sprite sheets you +can drop in to replace the placeholder art windows, with exact dimensions, +layout, and frame-by-frame maps. + +All art is wired through **`public/data/dungeonboss-artwork.json`**. The scene +checks whether each sheet's texture is loaded; if it is, it uses your frame, +otherwise it falls back to procedural drawing. You never need to touch code — +just add a PNG, set its `path` in that JSON (all four are currently empty), +and reload. + +Frame numbering is **row-major, 0-based**: frame 0 is top-left, count +left-to-right then down to the next row. + +Only paint the illustration — the card frame, border, name plate, cost/damage +badges, treasure-class icons, and rules text are all drawn procedurally on top +of your art. Art is scaled to **fit** the art window (no cropping), so match +the aspect ratio below for edge-to-edge fill; other aspects letterbox inside +the window. + +--- + +## 1. Room sheet — `dungeonboss-rooms.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-rooms.png` | +| **Frame size** | **300 × 200 px** (aspect 1.5 : 1, landscape — matches the room art window) | +| **Sheet size** | **6 columns × 6 rows = 1800 × 1200 px** holds all 31 frames (5 spare) | +| **Status** | ⛔ Procedural until you create it (then set `roomSheet.path` in the JSON) | +| **JSON** | `roomSheet` (set `path`) + `rooms` map (already filled in for all 31 ids) | + +Rooms tile into each player's dungeon wing (shown small, ~90×60 to ~168×112 on +the board) and larger in the reward-row/inspect views (up to ~380×260). Two +room **types** exist — `monster` (a creature guards the room) and `trap` (an +environmental hazard) — lean into that flavor per room below. + +### Frame map + +| Frame | id | Name | Type | Advanced | Notes | +|---:|---|---|---|---|---| +| 0 | `darkaltar` | Dark Altar | trap | | cleric treasure | +| 1 | `opengrave` | Open Grave | trap | | cleric treasure | +| 2 | `specterssanctum` | Specter's Sanctum | monster | | cleric treasure | +| 3 | `succubusspa` | Succubus Spa | monster | | cleric treasure | +| 4 | `dracolichlair` | Dracolich Lair | monster | advanced | cleric treasure | +| 5 | `vampirebordello` | Vampire Bordello | monster | advanced | cleric treasure | +| 6 | `goblinarmory` | Goblin Armory | monster | | fighter treasure | +| 7 | `golemfactory` | Golem Factory | monster | | fighter treasure | +| 8 | `minotaursmaze` | Minotaur's Maze | monster | | fighter treasure | +| 9 | `neanderthalcave` | Neanderthal Cave | monster | | fighter treasure | +| 10 | `beastmenagerie` | Beast Menagerie | monster | advanced | fighter treasure | +| 11 | `monstersballroom` | Monster's Ballroom | monster | advanced | fighter treasure | +| 12 | `brainsuckerhive` | Brainsucker Hive | monster | | mage treasure | +| 13 | `darklaboratory` | Dark Laboratory | trap | | mage treasure | +| 14 | `hauntedlibrary` | Haunted Library | trap | | mage treasure | +| 15 | `witchskitchen` | Witch's Kitchen | monster | | mage treasure | +| 16 | `allseeingeye` | All-Seeing Eye | trap | advanced | mage treasure | +| 17 | `ligersden` | Liger's Den | monster | advanced | mage treasure | +| 18 | `bottomlesspit` | Bottomless Pit | trap | | thief treasure | +| 19 | `boulderramp` | Boulder Ramp | trap | | thief treasure | +| 20 | `dizzygashallway` | Dizzygas Hallway | trap | | thief treasure | +| 21 | `jackpotstash` | Jackpot Stash | trap | | thief treasure | +| 22 | `recyclingcenter` | Recycling Center | trap | advanced | thief treasure | +| 23 | `thecrushinator` | The Crushinator | trap | advanced | thief treasure | +| 24 | `centipedetunnel` | Centipede Tunnel | monster | | fighter+mage treasure | +| 25 | `constructionzone` | Construction Zone | trap | | fighter+thief treasure | +| 26 | `dragonhatchery` | Dragon Hatchery | monster | | all-class treasure | +| 27 | `mimicvault` | Mimic Vault | trap | | mage+thief treasure | +| 28 | `monstrousmonument` | Monstrous Monument | trap | | cleric+fighter treasure | +| 29 | `torturechamber` | Torture Chamber | trap | | cleric+thief treasure | +| 30 | `zombieprison` | Zombie Prison | monster | | cleric+mage treasure | + +> Priority: rooms 0–17 (Cleric/Fighter/Mage wings) show up most often; the +> multi-class rooms (24–30) and advanced rooms (4, 5, 10, 11, 16, 17, 22, 23) +> are lower frequency but higher visual payoff (bigger/scarier). + +--- + +## 2. Spell sheet — `dungeonboss-spells.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-spells.png` | +| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the spell art window) | +| **Sheet size** | **4 columns × 4 rows = 880 × 1200 px** holds all 16 frames exactly | +| **Status** | ⛔ Procedural until you create it (then set `spellSheet.path` in the JSON) | +| **JSON** | `spellSheet` (set `path`) + `spells` map (already filled in for all 16 ids) | + +Spells are shown in-hand at small size and up to ~280×236 in the play/reveal +zoom. These are one-shot magical effects — go for a punchy central icon/scene +(a rune, a burst of energy, a monster silhouette) rather than a busy tableau. + +### Frame map + +| Frame | id | Name | Effect flavor | +|---:|---|---|---| +| 0 | `annihilator` | Annihilator | destructive burst | +| 1 | `assassin` | Assassin | targeted kill/damage | +| 2 | `cavein` | Cave In | collapsing rock/trap | +| 3 | `counterspell` | Counterspell | magical negation | +| 4 | `exhaustion` | Exhaustion | draining/weakening | +| 5 | `fear` | Fear | terror/scare effect | +| 6 | `freeze` | Freeze | ice/stasis | +| 7 | `giantsize` | Giant Size | growth/enlarge | +| 8 | `jeopardy` | Jeopardy | risk/gambling theme | +| 9 | `koboldstrike` | Kobold Strike | small creature ambush | +| 10 | `motivation` | Motivation | rallying/buff | +| 11 | `princessinperil` | Princess in Peril | damsel/rescue-bait theme | +| 12 | `soulharvest` | Soul Harvest | dark reaping magic | +| 13 | `teleportation` | Teleportation | portal/warp | +| 14 | `trepidation` | Trepidation | dread/hesitation | +| 15 | `zombieattack` | Zombie Attack | undead horde | + +--- + +## 3. Hero sheet — `dungeonboss-heroes.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-heroes.png` | +| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the hero art window) | +| **Sheet size** | **7 columns × 6 rows = 1540 × 1800 px** holds all 41 frames (1 spare) | +| **Status** | ⛔ Procedural until you create it (then set `heroSheet.path` in the JSON) | +| **JSON** | `heroSheet` (set `path`) + `heroes` map (already filled in for all 41 ids) | + +Heroes are the townsfolk marching toward the dungeons — shown small on the +town track (~106×148) and up to ~280×390 in the inspect view. Class color +coding: **Fighter** red, **Mage** blue, **Thief** gold, **Cleric** teal (see +`CLASS_INFO` in `DungeonBossData.js` for exact hex). **Epic** heroes (higher +HP, named/unique-flavor) should read as visually grander than ordinary ones. + +### Frame map + +Class legend: **F** = Fighter, **M** = Mage, **T** = Thief, **C** = Cleric. + +| Frame | id | Name | Class | HP | Epic | +|---:|---|---|---|---:|---| +| 0 | `nick` | Nick the Masher | C | 4 | | +| 1 | `pugi` | Pugi the Druidess | C | 4 | | +| 2 | `acacia` | Acacia, Warrior of Light | C | 6 | | +| 3 | `charles` | Charles the Young | C | 6 | | +| 4 | `delatorious` | Delatorious | C | 8 | | +| 5 | `romero` | Romero, the Indigo Friar | C | 8 | | +| 6 | `boden` | Boden the Pantless | F | 4 | | +| 7 | `jarek` | Jarek the Squire | F | 4 | | +| 8 | `firesbreath` | Fire's Breath | F | 6 | | +| 9 | `samuraitom` | Samurai Tom | F | 6 | | +| 10 | `johnny` | Johnny of the Evening Watch | F | 8 | | +| 11 | `crystolalan` | Crystol & A'lan of Gerd | F | 8 | | +| 12 | `tieg` | Tieg and the Magic Bubble | M | 4 | | +| 13 | `brandork` | Brandork the Neverwrong | M | 4 | | +| 14 | `mitchell` | Mitchell, the Judge | M | 6 | | +| 15 | `kalish` | Kalish Ninefingers | M | 6 | | +| 16 | `dartteon` | Dartteon, Elf Pyromancer | M | 8 | | +| 17 | `koey` | Koey, the Last Dragon Mage | M | 8 | | +| 18 | `joman` | Joman Chimm, Cutpurse | T | 4 | | +| 19 | `lance` | Lance Uppercut | T | 4 | | +| 20 | `jesta` | Jesta the Rogue | T | 6 | | +| 21 | `kins` | Kins Klauski | T | 6 | | +| 22 | `digby` | Sir Digby Apple | T | 8 | | +| 23 | `jerome` | Jerome, Kung Fu Monkey | T | 8 | | +| 24 | `fool` | The Fool | — | 2 | jester, no class colors | +| 25 | `katelyn` | Katelyn, Angelic Healer | C | 11 | ✅ | +| 26 | `kerberos` | Kerberos Dirtbeard | C | 11 | ✅ | +| 27 | `vanette` | Lord Van Ette | C | 13 | ✅ | +| 28 | `jejune` | Jejune & Everlea | C | 13 | ✅ | +| 29 | `frankov` | Frankov, the Envoy | F | 11 | ✅ | +| 30 | `nate` | Nate the Squidslayer | F | 11 | ✅ | +| 31 | `antonius` | Antonius, the Rune Knight | F | 13 | ✅ | +| 32 | `asmor` | Asmor the Aweless | F | 13 | ✅ | +| 33 | `tempros` | Tempros the Time Marauder | M | 11 | ✅ | +| 34 | `chiakang` | Chia Kang, Warlock of Yu | M | 11 | ✅ | +| 35 | `terric` | Terric Warhelm | M | 13 | ✅ | +| 36 | `wayward` | Wayward, the Drifter | M | 13 | ✅ | +| 37 | `hya` | Hya, Legendary Shinobi | T | 11 | ✅ | +| 38 | `blackbeard` | Blackbeard Jake | T | 11 | ✅ | +| 39 | `cecil` | Cecil Leoran, Factotum | T | 13 | ✅ | +| 40 | `wallbanger` | Wallbanger Basketweaver | T | 13 | ✅ | + +> Priority: the 24 ordinary heroes (0–23) plus the Fool (24) are seen every +> game and every player count — paint those first. The 16 epics (25–40) are +> lower-frequency but should look noticeably more impressive/detailed. + +--- + +## 4. Boss sheet — `dungeonboss-bosses.png` + +| | | +|---|---| +| **Path** | `public/assets/images/dungeonboss-bosses.png` | +| **Frame size** | **260 × 360 px** (aspect ≈ 0.722 : 1, large portrait — matches the boss art window) | +| **Sheet size** | **4 columns × 2 rows = 1040 × 720 px** holds all 8 frames exactly | +| **Status** | ⛔ Procedural until you create it (then set `bossSheet.path` in the JSON) | +| **JSON** | `bossSheet` (set `path`) + `bosses` map (already filled in for all 8 ids) | + +Each player picks one boss at the start of the game — shown persistently on +their dungeon (~88×124 to ~168×236) and full-size (~300×430) in the reveal/win +screen. These are the marquee art assets of the game: eight distinct, iconic +dungeon-master portraits. Higher XP bosses go first in turn order, so no +in-art ranking is implied — just make each one visually distinct and readable +at the small on-board size. + +### Frame map + +| Frame | id | Name | XP | Treasure class | Level-up flavor | +|---:|---|---|---:|---|---| +| 0 | `draculord` | Draculord | 900 | cleric | vampire lord, steals a card | +| 1 | `cleopatra` | Cleopatra | 850 | thief | Egyptian queen, fetches advanced trap | +| 2 | `kingcroak` | King Croak | 800 | fighter | frog king, fetches advanced monster | +| 3 | `xyzax` | Xyzax | 750 | cleric | alien/otherworldly, recovers discards | +| 4 | `cerebellus` | Cerebellus | 650 | mage | brain/psychic horror, draws spells | +| 5 | `seducia` | Seducia | 600 | mage | succubus/temptress, lures a hero | +| 6 | `gorgona` | Gorgona | 500 | thief | gorgon/medusa, turns a hero to stone | +| 7 | `robobo` | Robobo | 400 | fighter | robot, wrecks opponents' rooms | + +--- + +## 5. Menu icon — `game-icons.png` frame **77** *(shared sheet)* + +| | | +|---|---| +| **File** | `public/assets/images/game-icons.png` (existing shared sheet) | +| **Sheet size** | 660 × 660 px | +| **Frame size** | **44 × 44 px** | +| **Layout** | 15 columns × 15 rows | +| **Dungeon Boss frame** | **77** → row 5, col 2 → pixel box **x 88–132, y 220–264** | + +Add a 44×44 icon for Dungeon Boss into cell 77 of the existing sheet (the menu +tile pulls `game-icons` frame `77`). Keep it readable at tiny size — e.g. a +grinning dungeon-entrance skull, crossed sword/wand, or a small horned boss +silhouette. This is the only sprite that goes into an **existing** sheet +rather than a new file. + +--- + +## Quick checklist + +- [ ] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (0–30); then set `roomSheet.path` in `dungeonboss-artwork.json`. +- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (0–15); then set `spellSheet.path` in `dungeonboss-artwork.json`. +- [ ] `dungeonboss-heroes.png` — 1540×1800 (7×6 grid), 220×300 per frame, 41 frames (0–40); then set `heroSheet.path` in `dungeonboss-artwork.json`. + - Priority 1: ordinary heroes + Fool (0–24) + - Priority 2: epic heroes (25–40) +- [ ] `dungeonboss-bosses.png` — 1040×720 (4×2 grid), 260×360 per frame, 8 frames (0–7); then set `bossSheet.path` in `dungeonboss-artwork.json`. +- [ ] `game-icons.png` frame 77 — 44×44 menu icon. + +The frame→id mappings for all four sheets are already filled in in +`public/data/dungeonboss-artwork.json` — once you drop the PNGs in and set the +four `path` fields, art appears with no code changes. diff --git a/src/games/dungeonboss/tutorial.md b/src/games/dungeonboss/tutorial.md new file mode 100644 index 0000000..f41b2d5 --- /dev/null +++ b/src/games/dungeonboss/tutorial.md @@ -0,0 +1,74 @@ +# Dungeon Boss + +> **A raspy voice echoes from beneath the mountain…** +> +> Ah, a new Boss. Wonderful. I am Grimble, the imp who files the paperwork for +> every dungeon in the valley. Yes, dungeons have paperwork. Whose soul do you +> think keeps the lamps lit? Sit, and I'll explain how a respectable villain +> earns a living down here: you build the deadliest dungeon in the land, lure +> in shiny-booted heroes, and *harvest their souls.* Try not to get stabbed. + +## Objective + +Every player is a **Boss** with a dungeon of up to **5 rooms**. Heroes arrive +in town each round and wander into whichever dungeon most tempts their greedy +little hearts. Kill them, and their souls are yours. Let them fight all the way +through your halls, and they'll wound *you* instead. + +- First Boss to **10 souls** at the end of a round **wins**. +- Take **5 wounds**, and your dungeon is closed down. Permanently. +- Epic heroes are worth **2 souls** — but they also deal **2 wounds**. + +## Setup + +You're dealt a Boss, **5 Room cards, and 2 Spell cards**; discard any 2 cards, +then everyone builds one starting room at the same time. Your Boss shows an +**XP value** — the highest XP acts first each round. (If the game ends in a +soul tie, the *lowest* XP wins. Underdogs get the paperwork bonus.) + +## A Round of Villainy + +1. **Heroes arrive** — one hero per player is revealed in town, and every Boss + draws a Room card. +2. **Build** — everyone secretly places one room face down (or passes), then + all flip at once. New rooms extend your dungeon toward the entrance; you can + also build *over* an old room. **Advanced rooms** (gold-gemmed) may only be + built over a room sharing a treasure type. +3. **Bait** — each hero compares dungeons and marches to the one showing the + **most treasure of its class**: Fighters follow swords, Mages tomes, Thieves + coin, Clerics holy symbols. Ties? The hero stays in town, dithering. + *The Fool* wanders to whoever has the fewest souls. +4. **Adventure** — heroes walk your dungeon room by room, from the entrance to + your throne. Each room deals its damage. Hero drops to zero? Soul harvested. + Hero survives every room? It pokes *you* — one wound (two if epic). + +## Rooms, Spells, and Dirty Tricks + +- **Monster rooms** and **trap rooms** each have damage, treasure icons, and + often an ability — some trigger when built, some when a hero dies inside, + and some you activate by *sacrificing* the room itself. +- **Spells** are cast at three moments: before builds, after the flip, and just + before heroes charge a dungeon. Build-phase spells (hammer label) shape the + board; adventure spells (adventure label) turn battles. You can boost your + own rooms, freeze a rival's, or *heal their hero* so it survives to bite them. +- Watch for **Counterspell** and the **All-Seeing Eye** — cast recklessly and + your masterstroke fizzles into the discard pile. + +## Leveling Up + +The first time your dungeon reaches **5 rooms**, your Boss **levels up** and +unleashes a one-time power — read your Boss card, every one is different. +Racing to five rooms is a fine plan. So is knocking rivals' rooms down before +they get there. + +## Grimble's Advice + +- Only bait heroes you can actually **kill** — count your damage before you + flash your treasure. A dungeon that attracts what it cannot slay is called + a *tomb*, and the paperwork is dreadful. +- Save a spell for the adventure phase. The difference between a soul and a + wound is often one well-timed boost. +- The soul leader draws everyone's knives. If that's you — build high, stay + humble, and keep the Eye open. + +Now go. Build something *horrible*. Make old Grimble proud. diff --git a/src/games/swdbg/SWDBGAI.js b/src/games/swdbg/SWDBGAI.js new file mode 100644 index 0000000..2ca6880 --- /dev/null +++ b/src/games/swdbg/SWDBGAI.js @@ -0,0 +1,290 @@ +// SWDBGAI.js — synchronous heuristics over the engine's public view (opponent +// hand/decks are stubs; the AI never sees hidden information). One handler per +// decision kind, routed through decide(). Skill 1–5 follows the house shape: +// widening top-N with score noise and a blunder chance at low skill. + +import { cardDef, baseDef, getData } from './SWDBGData.js'; +import { legalActions, entryAttack, forceWith, purchaseCost } from './SWDBGLogic.js'; + +const SKILL_PROFILES = { + 1: { topN: 4, blunder: 0.35, noise: 3.0, actChance: 0.35, bountyChance: 0.25, delay: [650, 1100] }, + 2: { topN: 3, blunder: 0.20, noise: 2.0, actChance: 0.55, bountyChance: 0.50, delay: [600, 1000] }, + 3: { topN: 3, blunder: 0.08, noise: 1.2, actChance: 0.80, bountyChance: 0.75, delay: [550, 950] }, + 4: { topN: 2, blunder: 0.02, noise: 0.6, actChance: 0.95, bountyChance: 0.90, delay: [480, 860] }, + 5: { topN: 1, blunder: 0.00, noise: 0.0, actChance: 1.00, bountyChance: 1.00, delay: [420, 800] }, +}; +function profileFor(skill) { + return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3]; +} +export function nextThinkDelay(skill) { + const [lo, hi] = profileFor(skill).delay; + return lo + Math.random() * (hi - lo); +} + +function pickScored(cands, skill, rnd) { + if (!cands.length) return null; + const prof = profileFor(skill); + if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice; + const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise })); + jittered.sort((a, b) => b.s - a.s); + return jittered[Math.floor(rnd() * Math.min(prof.topN, jittered.length))].choice; +} + +const me = (view, seat) => view.players[seat]; +const foe = (view, seat) => view.players[1 - seat]; + +// Deck-building quality of a card — used for buys, discards, and exiles. +export function cardValue(def) { + let v = (def.attack || 0) + (def.resources || 0) + (def.force || 0) * 0.7; + if (def.ability) v += 1.2; + if (def.type === 'capital') v += (def.hp || 0) * 0.25 + (def.resources || 0) * 1.3; + if (def.unique) v += 0.5; + return v; +} + +function worstHandCard(view, seat, { exclude = null } = {}) { + const hand = me(view, seat).hand.filter((c) => c.uid !== exclude); + if (!hand.length) return null; + return hand.slice().sort((a, b) => cardValue(cardDef(a)) - cardValue(cardDef(b)))[0]; +} + +// ── turn ───────────────────────────────────────────────────────────────────── +function chooseTurn(view, seat, skill, rnd) { + const prof = profileFor(skill); + const p = me(view, seat); + const o = foe(view, seat); + const legal = legalActions(view, seat); + + // 1. Play out the hand. Hold one cheap card back as Jabba/exile-cost fodder + // when an unused exileFromHand ability is on the table. + if (legal.plays.length) { + const needsFodder = [...p.inPlay, ...p.capitals].some((e) => { + const ab = cardDef(e.card).ability; + return ab?.timing === 'action' && ab.cost?.exileFromHand && !e.used; + }); + let playable = p.hand; + if (needsFodder && p.hand.length === 1) playable = []; + if (playable.length) { + // Worst first, so kept fodder is the cheapest card. + const pick = playable.slice().sort((a, b) => cardValue(cardDef(b)) - cardValue(cardDef(a)))[0]; + return { type: 'play', uid: pick.uid }; + } + } + + // 2. Abilities (skill-gated). + if (legal.abilities.length && rnd() < prof.actChance) { + const a = legal.abilities[0]; + const action = { type: 'ability', zone: a.zone, uid: a.uid }; + // Supply the exile cost card when needed. + const def = a.zone === 'base' ? baseDef(p.faction, p.base.id) : cardDef(findInPlay(p, a.uid).card); + if (def.ability?.cost?.exileFromHand) { + const fodder = worstHandCard(view, seat); + if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal); + action.costUid = fodder.uid; + } + return action; + } + + return nextAfterAbilities(view, seat, skill, rnd, legal); +} + +function findInPlay(p, uid) { + return p.inPlay.find((e) => e.card.uid === uid) || p.capitals.find((e) => e.card.uid === uid); +} + +function nextAfterAbilities(view, seat, skill, rnd, legal) { + const prof = profileFor(skill); + const p = me(view, seat); + const o = foe(view, seat); + + // 3. Bounty hunting / sabotage — spend the smallest sufficient attacker set + // when the reward + denial beats the base damage forfeited. + if (legal.rowTargets.length && rnd() < prof.bountyChance) { + const options = []; + for (const t of legal.rowTargets) { + const target = view.galaxy.row.find((c) => c.uid === t.uid); + const tdef = cardDef(target); + const picked = pickBountySquad(view, seat, tdef.target); + if (!picked) continue; + const r = tdef.reward || {}; + const rewardV = (r.resources || 0) + (r.force || 0) * 1.2 + (r.draw || 0) * 1.8; + const denialV = cardValue(tdef) * 0.45; // enemy can no longer buy it + const spent = picked.total * 0.7; // base damage forfeited + const s = rewardV + denialV - spent; + if (s > -0.5) options.push({ choice: { type: 'attackRow', targetUid: t.uid, uids: picked.uids }, s }); + } + const pick = pickScored(options, skill, rnd); + if (pick) return pick; + } + + // 4. Buys, best value per resource. + if (legal.buys.length) { + const options = [{ choice: null, s: 0.4 }]; // skipping junk is allowed + for (const b of legal.buys) { + const def = b.uid === 'outerrim' ? getData().json.outerRimPilot + : cardDef(view.galaxy.row.find((c) => c.uid === b.uid)); + let s = cardValue(def) - b.cost * 0.45; + if (def.faction === p.faction) s += 0.5; // faction synergy & denial-proofing + if (def.type === 'capital' && o.lostBases >= 2) s -= 1; // late capitals rarely pay off + if (view.turnCount <= 6 && def.cost <= 2 && def.ability) s += 0.4; // early thinning/tempo + options.push({ choice: { type: 'buy', uid: b.uid }, s }); + } + const pick = pickScored(options, skill, rnd); + if (pick) return pick; + } + + // 5. Attack the base with everything still uncommitted. + if (legal.baseAttackers.length) { + const total = legal.baseAttackers.reduce((a, uid) => a + entryAttack(view, seat, findInPlay(p, uid)), 0); + if (total > 0) { + const order = o.capitals.slice() + .sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage)) + .map((e) => e.card.uid); + return { type: 'attackBase', uids: legal.baseAttackers, capitalOrder: order }; + } + } + + return { type: 'endTurn' }; +} + +// Smallest set of free units whose row attack meets the target. +function pickBountySquad(view, seat, target) { + const p = me(view, seat); + const free = p.inPlay.filter((e) => !e.committed) + .map((e) => ({ uid: e.card.uid, atk: entryAttack(view, seat, e, { vsRow: true }) })) + .filter((e) => e.atk > 0) + .sort((a, b) => a.atk - b.atk); + const total = free.reduce((a, e) => a + e.atk, 0); + if (total < target) return null; + // Greedy from the top down, then trim from the bottom. + const picked = []; + let sum = 0; + for (let i = free.length - 1; i >= 0 && sum < target; i--) { picked.push(free[i]); sum += free[i].atk; } + for (let i = picked.length - 1; i >= 0; i--) { + if (sum - picked[i].atk >= target) { sum -= picked[i].atk; picked.splice(i, 1); } + } + return { uids: picked.map((e) => e.uid), total: sum }; +} + +// ── other decisions ────────────────────────────────────────────────────────── +function chooseBase(view, seat, skill, rnd, decision) { + const p = me(view, seat); + const options = decision.options.map((id) => { + const b = baseDef(p.faction, id); + let s = b.hp * 0.5; + const ab = b.ability; + if (ab) { + if (ab.op === 'draw') s += 2.5; + if (ab.op === 'gainResources') s += 2; + if (ab.op === 'capitalDiscount') s += view.turnCount <= 8 ? 2 : 0.5; + if (ab.op === 'onPlayTraitAttack') s += 1.5; + if (ab.op === 'damageBase') s += 2.5; + if (ab.op === 'exileCards') s += view.turnCount <= 8 ? 1.5 : 0.3; + } + // Behind on bases? Prefer raw hit points. + if (p.lostBases > foe(view, seat).lostBases) s += b.hp * 0.25; + return { choice: id, s }; + }); + return pickScored(options, skill, rnd); +} + +function chooseTarget(view, seat, skill, rnd, decision) { + const p = me(view, seat); + const cands = decision.candidates || []; + const scored = []; + const skipOk = decision.optional; + switch (decision.op) { + case 'discardRow': + for (const c of cands) { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + const enemy = def.faction !== 'neutral' && def.faction !== p.faction; + scored.push({ choice: c, s: enemy ? cardValue(def) : cardValue(def) * 0.2 - 1 }); + } + if (skipOk) scored.push({ choice: null, s: 1.5 }); + break; + case 'destroyCapital': + for (const c of cands) { + if (c.kind === 'capital') { + const e = foe(view, seat).capitals.find((x) => x.card.uid === c.uid); + scored.push({ choice: c, s: e ? cardValue(cardDef(e.card)) + 2 : 0 }); + } else { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: def.faction !== p.faction && def.faction !== 'neutral' ? cardValue(def) : 0.5 }); + } + } + break; + case 'exileCards': + for (const c of cands) { + const zone = c.zone === 'hand' ? p.hand : p.discard; + const def = cardDef(zone.find((x) => x.uid === c.uid)); + // Exile starters and junk; never exile good cards. + scored.push({ choice: c, s: 2.2 - cardValue(def) }); + } + if (skipOk) scored.push({ choice: null, s: 0 }); + break; + case 'recoverDiscard': + for (const c of cands) { + const def = cardDef(p.discard.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: cardValue(def) }); + } + break; + case 'freePurchase': + for (const c of cands) { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: cardValue(def) }); + } + break; + case 'lookTop': case 'revealTopDiscard': { + const def = decision.peekId ? cardDef(decision.peekId) : null; + const enemy = def && def.faction !== 'neutral' && def.faction !== p.faction; + const junk = def && cardValue(def) < 2; + const discard = enemy || junk; + return cands.find((c) => c.v === (discard ? 'discard' : 'keep')) || cands[0]; + } + default: + return decision.optional ? null : cands[0] || null; + } + const pick = pickScored(scored, skill, rnd); + return pick === undefined ? null : pick; +} + +function chooseOppDiscard(view, seat) { + const worst = worstHandCard(view, seat); + return worst ? worst.uid : null; +} + +function chooseSpy(view, seat, decision) { + if (!decision.options.includes('discard')) return 'force'; + if (!decision.options.includes('force')) return 'discard'; + const worst = worstHandCard(view, seat); + return worst && cardValue(cardDef(worst)) <= 1.2 ? 'discard' : 'force'; +} + +function chooseOption(view, seat, skill, rnd, decision) { + const o = foe(view, seat); + const scored = decision.options.map((ab, i) => { + let s = 1; + if (ab.op === 'draw') s = (ab.n || 1) * 1.4; + if (ab.op === 'oppDiscard') s = o.hand.length ? 1.6 : 0; + if (ab.op === 'gainResources') s = (ab.n || 1) * 1.0; + return { choice: i, s }; + }); + return pickScored(scored, skill, rnd) ?? 0; +} + +// ── Router ────────────────────────────────────────────────────────────────── +// Returns the argument for the matching act* mutator: +// turn → action object · chooseBase → baseId · target → ref|null · +// oppDiscard → uid · spyChoice → 'discard'|'force' · chooseOption → idx +export function decide(view, decision, skill, rnd = Math.random) { + const seat = decision.seat; + switch (decision.kind) { + case 'turn': return chooseTurn(view, seat, skill, rnd); + case 'chooseBase': return chooseBase(view, seat, skill, rnd, decision); + case 'target': return chooseTarget(view, seat, skill, rnd, decision); + case 'oppDiscard': return chooseOppDiscard(view, seat); + case 'spyChoice': return chooseSpy(view, seat, decision); + case 'chooseOption': return chooseOption(view, seat, skill, rnd, decision); + default: return null; + } +} diff --git a/src/games/swdbg/SWDBGData.js b/src/games/swdbg/SWDBGData.js new file mode 100644 index 0000000..5a5bf86 --- /dev/null +++ b/src/games/swdbg/SWDBGData.js @@ -0,0 +1,91 @@ +// SWDBGData.js — data layer for Star Wars Deckbuilder. +// All card content lives in /data/swdbg-cards.json (user-editable); this module +// indexes the parsed JSON and exposes lookups. loadCardData() must be called +// before the engine is used: the Phaser scene feeds it the preloaded JSON and +// tools/verifySWDBG.js reads the file from disk. + +export const FACTIONS = ['empire', 'rebel']; + +export const FACTION_INFO = { + empire: { label: 'Galactic Empire', color: 0x4d7fd6, colorHex: '#4d7fd6', dark: 0x16233d, darkHex: '#1c3a70', symbol: '◉' }, + rebel: { label: 'Rebel Alliance', color: 0xd6604d, colorHex: '#d6604d', dark: 0x3d1a16, darkHex: '#8a3223', symbol: '✴' }, + neutral: { label: 'Neutral', color: 0xc9a54a, colorHex: '#c9a54a', dark: 0x332a14, darkHex: '#7a5c14', symbol: '◈' }, +}; + +// Every op the engine understands. verifySWDBG.js checks each card in the JSON +// against this list so a data edit can't silently no-op. +export const OPS = [ + 'draw', 'gainResources', 'gainForce', 'gainAttack', 'damageBase', 'repairBase', + 'oppDiscard', 'discardRow', 'destroyCapital', 'exileCards', 'freePurchase', + 'recoverDiscard', 'lookTop', 'revealTop', 'spyChoice', 'choose', + 'traitBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', + 'capitalDiscount', 'onPlayTraitAttack', +]; +export const TIMINGS = ['action', 'constant', 'onPlay', 'onBounty', 'onReveal']; +// Ops that are passive modifiers — never activated as an action. +export const CONSTANT_OPS = ['traitBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', 'capitalDiscount', 'onPlayTraitAttack']; + +let DATA = null; + +export function loadCardData(json) { + if (!json || !json.cards) throw new Error('swdbg-cards.json missing or malformed'); + const byId = new Map(); + const add = (def) => { + if (byId.has(def.id)) throw new Error(`duplicate card id ${def.id}`); + byId.set(def.id, def); + }; + for (const def of json.starterCards) add(def); + add(json.outerRimPilot); + for (const def of json.cards) add(def); + const bases = { rebel: new Map(), empire: new Map() }; + for (const f of FACTIONS) for (const b of json.bases[f]) bases[f].set(b.id, b); + DATA = { json, byId, bases, meta: json.meta }; + return DATA; +} + +export function getData() { + if (!DATA) throw new Error('loadCardData() must be called before using the engine'); + return DATA; +} + +export function cardDef(idOrInst) { + const id = typeof idOrInst === 'string' ? idOrInst : idOrInst?.id; + const def = getData().byId.get(id); + if (!def) throw new Error(`unknown card id ${id}`); + return def; +} + +export function baseDef(faction, id) { + const def = getData().bases[faction].get(id); + if (!def) throw new Error(`unknown ${faction} base ${id}`); + return def; +} + +export function basesFor(faction) { return getData().json.bases[faction]; } + +let uidCounter = 0; +export function makeInstance(id) { return { uid: ++uidCounter, id }; } +export function resetUids() { uidCounter = 0; } + +// Deck construction ---------------------------------------------------------- + +export function buildGalaxyDeck() { + const out = []; + for (const def of getData().json.cards) { + for (let i = 0; i < def.count; i++) out.push(makeInstance(def.id)); + } + return out; +} + +export function buildStarterDeck(faction) { + const out = []; + for (const entry of getData().json.starters[faction]) { + for (let i = 0; i < entry.count; i++) out.push(makeInstance(entry.id)); + } + return out; +} + +export function buildOuterRim() { + const def = getData().json.outerRimPilot; + return Array.from({ length: def.count }, () => makeInstance(def.id)); +} diff --git a/src/games/swdbg/SWDBGGame.js b/src/games/swdbg/SWDBGGame.js new file mode 100644 index 0000000..62c3694 --- /dev/null +++ b/src/games/swdbg/SWDBGGame.js @@ -0,0 +1,1333 @@ +// SWDBGGame.js — Phaser scene for Star Wars Deckbuilder (presentation only; all +// rules live in SWDBGLogic). The scene pumps the engine's decision queue: +// human decisions enable click UI, AI decisions run on a think delay, and each +// action's event stream drives a sequential animation queue before re-render. +// +// Interaction model on your turn: +// · click a hand card to play it (or Play All) +// · click a card in play to toggle it into your attack squad, then click the +// enemy base panel or a highlighted galaxy-row target to strike +// · USE pills appear under cards whose ability is available +// · click a highlighted galaxy-row card (no squad selected) to purchase it + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { api } from '../../services/api.js'; +import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; +import { loadCardData, cardDef, baseDef, basesFor, getData, FACTION_INFO } from './SWDBGData.js'; +import { + newGame, pendingDecision, takeEvents, publicView, legalActions, + actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, + entryAttack, forceWith, purchaseCost, FORCE_MAX, +} from './SWDBGLogic.js'; +import { decide, nextThinkDelay } from './SWDBGAI.js'; + +const C = { + bg: 0x05070f, bgTop: 0x0a0f1f, + panel: 0x0d1424, panelEdge: 0x22304f, + text: '#e8ecf5', muted: '#8a94ab', gold: 0xc9a54a, goldHex: '#c9a54a', + good: 0x3fbf6f, goodHex: '#3fbf6f', bad: 0xd6604d, badHex: '#e06c5c', + empire: 0x4d7fd6, rebel: 0xd6604d, neutral: 0xc9a54a, + artWindow: 0x111a30, plate: 0x1a2440, + // Card face background — slightly off-white, with darker ink variants for + // the small bits of text that sit directly on it (outside the plate/art- + // window/badge boxes, which stay dark and keep their existing light text). + cardBg: 0xf1ede1, + statAtkHex: '#a8362a', statResHex: '#8a6a14', statForceHex: '#1a5f96', + bountyHex: '#7a3fa8', baseTextHex: '#38414f', +}; +const DEPTH = { board: 10, row: 20, hand: 40, fx: 60, ui: 80, overlay: 90 }; + +export default class SWDBGGame extends Phaser.Scene { + constructor() { super('SWDBGGame'); } + + init(data) { + this._initData = data; + this.gameDef = data?.game ?? { slug: 'swdbg', name: 'Star Wars Deckbuilder' }; + this.opponents = data?.opponents ?? []; + this.playfield = data?.playfield ?? null; + this.aiSkill = this.opponents[0]?.skill ?? 3; + this.humanSeat = 0; + this.gs = null; + this.busy = false; + this.mode = { type: 'idle' }; + this.squad = new Set(); // uids committed to the next attack + this._recorded = false; + this._rowPos = new Map(); // uid → {x,y} + this._playPos = new Map(); // uid → {x,y} + this._actionButtons = []; + } + + create() { + try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) { /* optional */ } + this.art = this.cache.json.get('swdbg-artwork') || {}; + loadCardData(this.cache.json.get('swdbg-cards')); + + this.drawBackground(); + this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board); + this.rowLayer = this.add.container(0, 0).setDepth(DEPTH.row); + this.handLayer = this.add.container(0, 0).setDepth(DEPTH.hand); + this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); + this.buildStaticUi(); + this.showFactionSelect(); + } + + drawBackground() { + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(0); + return; + } + const bg = this.add.graphics().setDepth(0); + bg.fillGradientStyle(C.bgTop, C.bgTop, C.bg, C.bg, 1); + bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + const rng = new Phaser.Math.RandomDataGenerator(['swdbg']); + for (let i = 0; i < 240; i++) { + const a = rng.frac() * 0.5 + 0.08; + bg.fillStyle(0xffffff, a); + bg.fillCircle(rng.between(0, GAME_WIDTH), rng.between(0, GAME_HEIGHT), rng.frac() * 1.6 + 0.4); + } + } + + buildStaticUi() { + new Button(this, 90, GAME_HEIGHT - 36, 'Leave', () => this.scene.start('GameMenu'), + { width: 130, fontSize: 18, variant: 'ghost' }).setDepth(DEPTH.ui); + this.promptText = this.add.text(GAME_WIDTH / 2, 648, '', { + fontFamily: 'Righteous', fontSize: '23px', color: C.goldHex, + backgroundColor: '#05070fdd', padding: { x: 16, y: 6 }, + }).setOrigin(0.5).setDepth(DEPTH.ui); + if (this.opponents[0]) createOpponentPortrait(this, this.opponents[0], 140, 128, 44, DEPTH.ui); + createPlayerPortrait(this, 140, 812, 44, DEPTH.ui, 'SWDBGGame'); + } + + // ── faction select ───────────────────────────────────────────────────────── + showFactionSelect() { + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + root.add(this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.72).setInteractive()); + root.add(this.add.text(GAME_WIDTH / 2, 250, 'CHOOSE YOUR SIDE', { + fontFamily: 'Righteous', fontSize: '52px', color: C.text, + }).setOrigin(0.5)); + root.add(this.add.text(GAME_WIDTH / 2, 316, 'The Empire always strikes first.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: C.muted, + }).setOrigin(0.5)); + const mk = (x, faction, blurb) => { + const info = FACTION_INFO[faction]; + const cont = this.add.container(x, 620); + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.96); g.fillRoundedRect(-240, -190, 480, 380, 16); + g.lineStyle(4, info.color, 1); g.strokeRoundedRect(-240, -190, 480, 380, 16); + cont.add(g); + cont.add(this.add.text(0, -120, info.symbol, { fontSize: '84px', color: info.colorHex }).setOrigin(0.5)); + cont.add(this.add.text(0, -20, info.label.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '34px', color: info.colorHex, + }).setOrigin(0.5)); + cont.add(this.add.text(0, 84, blurb, { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.text, align: 'center', + wordWrap: { width: 420 }, lineSpacing: 6, + }).setOrigin(0.5)); + cont.setSize(480, 380); + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerover', () => this.tweens.add({ targets: cont, scale: 1.04, duration: 120 })); + cont.on('pointerout', () => this.tweens.add({ targets: cont, scale: 1, duration: 120 })); + cont.on('pointerdown', () => { root.destroy(); this.startGame(faction); }); + root.add(cont); + }; + mk(GAME_WIDTH / 2 - 300, 'empire', 'Overwhelming firepower.\nTIE swarms, Star Destroyers,\nand the will of the Emperor.\nYou move first.'); + mk(GAME_WIDTH / 2 + 300, 'rebel', 'Hope, grit, and X-Wings.\nThe Force begins on your side\nof the Balance track.'); + } + + startGame(faction) { + this.gs = newGame({ humanFaction: faction }); + takeEvents(this.gs); + this.renderAll(); + this.showBanner(faction === 'empire' ? 'You command the Empire — your turn first' : 'The Empire moves first — hold the line'); + this.pump(); + } + + // ── art lookup ───────────────────────────────────────────────────────────── + artFor(id, base = false) { + const sheet = base ? this.art.baseSheet : this.art.cardSheet; + const frame = (base ? this.art.bases : this.art.cards)?.[id]; + if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) { + return { key: sheet.key, frame }; + } + return null; + } + + factionColor(faction) { return FACTION_INFO[faction]?.color ?? C.neutral; } + + // ── card renderers ───────────────────────────────────────────────────────── + makeCard(x, y, inst, w, h, opts = {}) { + const def = cardDef(inst); + const cont = this.add.container(x, y); + const edge = opts.selected ? C.gold : this.factionColor(def.faction); + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(Math.max(2, w / 55), edge, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); + if (def.unique) { g.lineStyle(1.5, C.gold, 0.85); g.strokeRoundedRect(-w / 2 + 4, -h / 2 + 4, w - 8, h - 8, 5); } + // name plate + const plateH = Math.max(15, h * 0.12); + g.fillStyle(C.plate, 1); g.fillRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, plateH, 3); + // art window + const artH = h * (opts.showText ? 0.34 : 0.5); + const artTop = -h / 2 + plateH + 8; + g.fillStyle(C.artWindow, 1); g.fillRect(-w / 2 + 7, artTop, w - 14, artH); + cont.add(g); + const art = this.artFor(inst.id); + if (art) { + const img = this.add.image(0, artTop + artH / 2, art.key, art.frame); + img.setScale(Math.min((w - 14) / Math.max(img.width, 1), artH / Math.max(img.height, 1))); + cont.add(img); + } else { + const glyph = def.type === 'capital' ? '🚀' : (def.traits || []).includes('Fighter') ? '🛩' + : (def.traits || []).includes('Trooper') ? '🪖' : FACTION_INFO[def.faction].symbol; + cont.add(this.add.text(0, artTop + artH / 2, glyph, { fontSize: `${Math.round(artH * 0.42)}px`, color: '#3d4d75' }).setOrigin(0.5).setAlpha(0.9)); + } + cont.add(this.add.text(2, -h / 2 + 5 + plateH / 2, def.name, { + fontFamily: 'Righteous', fontSize: `${Math.max(9, Math.round(w / 11.5))}px`, color: C.text, + }).setOrigin(0.5).setScale(Math.min(1, (w - 34) / Math.max(1, def.name.length * (w / 17))))); + // cost badge + const cr = Math.max(9, w / 12); + const cb = this.add.graphics(); + cb.fillStyle(0x0a0f1f, 1); cb.fillCircle(-w / 2 + cr + 3, -h / 2 + cr + 3, cr + 2); + cb.fillStyle(C.gold, 1); cb.fillCircle(-w / 2 + cr + 3, -h / 2 + cr + 3, cr); + cont.add(cb); + cont.add(this.add.text(-w / 2 + cr + 3, -h / 2 + cr + 3, `${def.cost}`, { + fontFamily: 'Righteous', fontSize: `${Math.round(cr * 1.15)}px`, color: '#0a0f1f', + }).setOrigin(0.5)); + // stat column under the art + let sy = artTop + artH + 13; + const stat = (glyph, val, color) => { + if (!val) return; + cont.add(this.add.text(-w / 2 + 12, sy, `${glyph}${val}`, { + fontFamily: 'Righteous', fontSize: `${Math.max(11, Math.round(w / 11))}px`, color, + }).setOrigin(0, 0.5)); + sy += Math.max(13, h * 0.085); + }; + const atk = opts.attackNow != null ? opts.attackNow : def.attack; + stat('⚔', atk, opts.attackNow != null && opts.attackNow !== def.attack ? C.statResHex : C.statAtkHex); + stat('▣', def.resources, C.statResHex); + stat('◈', def.force, C.statForceHex); + if (def.type === 'capital') { + const hr = Math.max(9, w / 13); + const hb = this.add.graphics(); + hb.fillStyle(0x0a0f1f, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr + 2); + hb.fillStyle(C.bad, 1); hb.fillCircle(w / 2 - hr - 4, h / 2 - hr - 4, hr); + cont.add(hb); + const hpLeft = opts.damage != null ? def.hp - opts.damage : def.hp; + cont.add(this.add.text(w / 2 - hr - 4, h / 2 - hr - 4, `${hpLeft}`, { + fontFamily: 'Righteous', fontSize: `${Math.round(hr * 1.1)}px`, color: '#fff', + }).setOrigin(0.5)); + } + // rules text + if (opts.showText && def.text) { + const boxTop = artTop + artH + Math.max(30, h * 0.2); + const boxH = h / 2 - boxTop - (def.target != null ? 24 : 8); + if (boxH > 18) { + const tg = this.add.graphics(); + tg.fillStyle(0x151d33, 1); tg.fillRoundedRect(-w / 2 + 7, boxTop, w - 14, boxH, 3); + cont.add(tg); + cont.add(this.add.text(0, boxTop + boxH / 2, def.text, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 16))}px`, color: '#c7cede', align: 'center', + wordWrap: { width: w - 22 }, + }).setOrigin(0.5)); + } + } + // bounty footer + if (def.target != null) { + const r = def.reward || {}; + const bits = []; + if (r.resources) bits.push(`${r.resources}▣`); + if (r.force) bits.push(`${r.force}◈`); + if (r.draw) bits.push(`draw ${r.draw}`); + cont.add(this.add.text(0, h / 2 - 12, `◎ ${def.target} → ${bits.join(' ')}`, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.max(9, Math.round(w / 14.5))}px`, color: C.bountyHex, + }).setOrigin(0.5)); + } + this.finishCard(cont, w, h, opts); + return cont; + } + + makeCardBack(x, y, w, h, parent, label = '') { + const cont = this.add.container(x, y); + const g = this.add.graphics(); + g.fillStyle(0x101a33, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(2, 0x2a3c66, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 7); + g.lineStyle(1, 0x2a3c66, 0.6); g.strokeRoundedRect(-w / 2 + 5, -h / 2 + 5, w - 10, h - 10, 5); + cont.add(g); + cont.add(this.add.text(0, -6, '✦', { fontSize: `${Math.round(h * 0.28)}px`, color: '#33477a' }).setOrigin(0.5)); + if (label) { + cont.add(this.add.text(0, h / 2 - 16, label, { + fontFamily: 'Righteous', fontSize: '15px', color: C.muted, + }).setOrigin(0.5)); + } + (parent || this.boardLayer).add(cont); + return cont; + } + + makeBaseCard(x, y, faction, base, w, h, opts = {}) { + const def = baseDef(faction, base.id); + const cont = this.add.container(x, y); + const info = FACTION_INFO[faction]; + const g = this.add.graphics(); + g.fillStyle(C.cardBg, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 9); + g.lineStyle(3, opts.highlight ? C.gold : info.color, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 9); + const artW = w * 0.42; + g.fillStyle(C.artWindow, 1); g.fillRect(-w / 2 + 6, -h / 2 + 6, artW, h - 12); + cont.add(g); + const art = this.artFor(def.id, true); + if (art) { + const img = this.add.image(-w / 2 + 6 + artW / 2, 0, art.key, art.frame); + img.setScale(Math.min(artW / Math.max(img.width, 1), (h - 12) / Math.max(img.height, 1))); + cont.add(img); + } else { + cont.add(this.add.text(-w / 2 + 6 + artW / 2, 0, '🪐', { fontSize: `${Math.round(h * 0.4)}px` }).setOrigin(0.5).setAlpha(0.85)); + } + const tx = -w / 2 + artW + 14; + cont.add(this.add.text(tx, -h / 2 + 17, def.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: `${Math.max(12, Math.round(w / 15))}px`, color: info.darkHex, + }).setOrigin(0, 0.5)); + if (opts.showText) { + cont.add(this.add.text(tx, -h / 2 + 36, def.text, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.baseTextHex, + wordWrap: { width: w - artW - 26 }, lineSpacing: 3, + }).setOrigin(0, 0)); + } + // hp bar + const hpLeft = Math.max(0, def.hp - base.damage); + const barW = w - artW - 28; + const bg2 = this.add.graphics(); + bg2.fillStyle(0x0a0f1f, 1); bg2.fillRoundedRect(tx, h / 2 - 26, barW, 16, 8); + bg2.fillStyle(hpLeft / def.hp > 0.4 ? C.good : C.bad, 1); + bg2.fillRoundedRect(tx, h / 2 - 26, barW * (hpLeft / def.hp), 16, 8); + cont.add(bg2); + cont.add(this.add.text(tx + barW / 2, h / 2 - 18, `${hpLeft} / ${def.hp}`, { + fontFamily: 'Righteous', fontSize: '13px', color: '#fff', + }).setOrigin(0.5)); + this.finishCard(cont, w, h, opts); + return cont; + } + + finishCard(cont, w, h, opts) { + if (opts.committed) { + const ov = this.add.graphics(); + ov.fillStyle(0x000000, 0.45); ov.fillRoundedRect(-w / 2, -h / 2, w, h, 7); + cont.add(ov); + cont.add(this.add.text(0, 0, '✓', { fontSize: `${Math.round(h * 0.3)}px`, color: '#8a94ab' }).setOrigin(0.5)); + } + if (opts.highlightBuy) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.good, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 9); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.35, duration: 520, yoyo: true, repeat: -1 }); + } + if (opts.highlightTarget) { + const hl = this.add.graphics(); + hl.lineStyle(3.5, C.bad, 1); hl.strokeRoundedRect(-w / 2 - 5, -h / 2 - 5, w + 10, h + 10, 9); + hl.lineStyle(2, C.bad, 0.8); + hl.lineBetween(-w / 2 - 12, 0, -w / 2 + 2, 0); hl.lineBetween(w / 2 - 2, 0, w / 2 + 12, 0); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.4, duration: 420, yoyo: true, repeat: -1 }); + } + if (opts.highlightGold) { + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); hl.strokeRoundedRect(-w / 2 - 4, -h / 2 - 4, w + 8, h + 8, 9); + cont.add(hl); + this.tweens.add({ targets: hl, alpha: 0.35, duration: 480, yoyo: true, repeat: -1 }); + } + if (opts.onClick) { + cont.setSize(w, h); + cont.setInteractive({ useHandCursor: true }); + if (opts.hover !== false) { + const baseY = cont.y; + cont.on('pointerover', () => { if (!this.busy) this.tweens.add({ targets: cont, y: baseY - 10, duration: 100 }); }); + cont.on('pointerout', () => this.tweens.add({ targets: cont, y: baseY, duration: 100 })); + } + cont.on('pointerdown', () => { if (!this.busy) opts.onClick(); }); + } + (opts.parent || this.boardLayer).add(cont); + } + + // ── full re-render ────────────────────────────────────────────────────────── + renderAll() { + this.boardLayer.removeAll(true); + this.rowLayer.removeAll(true); + this.handLayer.removeAll(true); + this._rowPos.clear(); + this._playPos.clear(); + if (!this.gs) return; + this.renderOpponent(); + this.renderGalaxy(); + this.renderForceTrack(); + this.renderHuman(); + this.renderHand(); + } + + seatName(seat) { return seat === this.humanSeat ? 'you' : (this.opponents[0]?.name || 'Opponent'); } + + renderOpponent() { + const gs = this.gs; + const seat = 1 - this.humanSeat; + const p = gs.players[seat]; + const info = FACTION_INFO[p.faction]; + const g = this.add.graphics(); + const attackable = this.squad.size > 0 && this.myTurnMode(); + g.fillStyle(C.panel, 0.6); g.fillRoundedRect(30, 34, GAME_WIDTH - 60, 212, 12); + g.lineStyle(2, attackable ? C.bad : C.panelEdge, 1); g.strokeRoundedRect(30, 34, GAME_WIDTH - 60, 212, 12); + this.boardLayer.add(g); + if (attackable) { + this.tweens.add({ targets: g, alpha: 0.5, duration: 420, yoyo: true, repeat: -1 }); + const z = this.add.zone(GAME_WIDTH / 2, 140, GAME_WIDTH - 60, 212).setInteractive({ useHandCursor: true }); + z.on('pointerdown', () => this.launchBaseAttack()); + this.boardLayer.add(z); + } + this.boardLayer.add(this.add.text(140, 196, this.opponents[0]?.name || 'Opponent', { + fontFamily: 'Righteous', fontSize: '17px', color: info.colorHex, + }).setOrigin(0.5)); + this.boardLayer.add(this.add.text(140, 220, info.label, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted, + }).setOrigin(0.5)); + // base + if (p.base) { + this.makeBaseCard(392, 140, p.faction, p.base, 300, 190, { + parent: this.boardLayer, hover: false, showText: true, + onClick: () => { if (this.squad.size && this.myTurnMode()) this.launchBaseAttack(); else this.showInspectBase(p.faction, p.base); }, + }); + } else { + this.boardLayer.add(this.add.text(392, 140, 'BASE DESTROYED', { + fontFamily: 'Righteous', fontSize: '22px', color: C.badHex, + }).setOrigin(0.5).setAngle(-6)); + } + this._oppBasePos = { x: 392, y: 140 }; + // capitals + p.capitals.forEach((e, i) => { + const x = 620 + i * 122; + this._playPos.set(e.card.uid, { x, y: 140 }); + this.makeCard(x, 140, e.card, 108, 152, { + parent: this.boardLayer, hover: false, damage: e.damage, + onClick: () => this.showInspect(e.card), + }); + }); + // opponent cards in play (their turn) + p.inPlay.forEach((e, i) => { + const x = 1210 + (i % 5) * 96; + const y = 100 + Math.floor(i / 5) * 88; + this._playPos.set(e.card.uid, { x, y }); + this.makeCard(x, y, e.card, 84, 118, { + parent: this.boardLayer, hover: false, committed: e.committed, + attackNow: entryAttack(gs, seat, e), + onClick: () => this.showInspect(e.card), + }); + }); + // counters + const cx = GAME_WIDTH - 220; + const lines = [ + `Hand ${p.hand.length} Deck ${p.deck.length}`, + `Discard ${p.discard.length}`, + `Resources ${p.resources}`, + ]; + this.boardLayer.add(this.add.text(cx, 70, lines.join('\n'), { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.muted, lineSpacing: 7, + })); + // bases remaining / lost + this.renderBasePips(cx, 168, p, info); + if (gs.turnSeat === seat && !gs.over) { + this.boardLayer.add(this.add.text(GAME_WIDTH / 2, 48, '— THEIR TURN —', { + fontFamily: 'Righteous', fontSize: '15px', color: info.colorHex, + }).setOrigin(0.5)); + } + } + + renderBasePips(x, y, p, info) { + this.boardLayer.add(this.add.text(x, y - 22, `Bases lost ${p.lostBases}/${this.gs.meta.basesToWin}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted, + })); + for (let i = 0; i < this.gs.meta.basesToWin; i++) { + const gg = this.add.graphics(); + const lost = i < p.lostBases; + gg.fillStyle(lost ? C.bad : 0x22304f, 1); + gg.fillCircle(x + 10 + i * 30, y + 8, 10); + if (lost) { gg.lineStyle(2, 0xffffff, 0.7); gg.lineBetween(x + 3 + i * 30, y + 1, x + 17 + i * 30, y + 15); } + this.boardLayer.add(gg); + } + } + + // ── galaxy band ───────────────────────────────────────────────────────────── + renderGalaxy() { + const gs = this.gs; + const y = 430; + const cw = 158, ch = 222; + // discard + deck + if (gs.galaxy.discard.length) { + const top = gs.galaxy.discard[gs.galaxy.discard.length - 1]; + this.makeCard(160, y, top, cw * 0.82, ch * 0.82, { parent: this.rowLayer, hover: false, onClick: () => this.showInspect(top) }); + this.rowLayer.add(this.add.text(160, y + ch / 2 + 4, `discard ${gs.galaxy.discard.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted, + }).setOrigin(0.5)); + } else { + this.rowLayer.add(this.add.text(160, y, 'GALAXY\nDISCARD', { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: '#2a3c66', align: 'center', + }).setOrigin(0.5)); + } + this.makeCardBack(330, y, cw * 0.9, ch * 0.9, this.rowLayer, `${gs.galaxy.deck.length}`); + // the row + const rowX0 = 510; + const legal = this.humanTurnLegal(); + gs.galaxy.row.forEach((card, i) => { + const def = cardDef(card); + const x = rowX0 + i * (cw + 14); + this._rowPos.set(card.uid, { x, y }); + const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === card.uid); + const targetable = this.squad.size > 0 && this.rowTargetReachable(card); + const freeTarget = this.mode.type === 'target' && ['discardRow', 'freePurchase', 'destroyCapital'].includes(this.mode.decision?.op) + && (this.mode.candidates || []).some((c) => c.uid === card.uid); + this.makeCard(x, y, card, cw, ch, { + parent: this.rowLayer, showText: true, + highlightBuy: buyable, highlightTarget: targetable, highlightGold: freeTarget, + onClick: () => this.onRowClicked(card), + }); + }); + // Outer Rim Pilot stack + if (gs.outerRim.length) { + const orp = getData().json.outerRimPilot; + const x = rowX0 + 6 * (cw + 14) + 20; + const buyable = !this.squad.size && legal && legal.buys.some((b) => b.uid === 'outerrim'); + this.makeCard(x, y, gs.outerRim[gs.outerRim.length - 1], cw, ch, { + parent: this.rowLayer, showText: true, highlightBuy: buyable, + onClick: () => this.onOuterRimClicked(), + }); + this.rowLayer.add(this.add.text(x, y + ch / 2 + 4, `${gs.outerRim.length} left`, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: C.muted, + }).setOrigin(0.5)); + this._orpPos = { x, y }; + } + } + + renderForceTrack() { + const gs = this.gs; + const cx = GAME_WIDTH / 2, y = 590; + const cellW = 66, cellH = 34; + const total = FORCE_MAX * 2 + 1; + const x0 = cx - (total * cellW) / 2; + const g = this.add.graphics(); + for (let i = 0; i < total; i++) { + const v = i - FORCE_MAX; // -3 .. +3 (empire … rebel) + const isMark = gs.force === v; + g.fillStyle(v < 0 ? 0x14213d : v > 0 ? 0x3d1a16 : 0x1a1a22, 1); + g.fillRoundedRect(x0 + i * cellW + 2, y - cellH / 2, cellW - 4, cellH, 6); + g.lineStyle(isMark ? 3 : 1, isMark ? 0xffffff : C.panelEdge, isMark ? 1 : 0.7); + g.strokeRoundedRect(x0 + i * cellW + 2, y - cellH / 2, cellW - 4, cellH, 6); + } + this.boardLayer.add(g); + const mx = x0 + (gs.force + FORCE_MAX) * cellW + cellW / 2; + const marker = this.add.graphics(); + marker.fillStyle(0xffffff, 1); marker.fillCircle(mx, y, 9); + marker.lineStyle(2, C.gold, 1); marker.strokeCircle(mx, y, 9); + this.boardLayer.add(marker); + this._forceMarkerPos = { x: mx, y }; + this.boardLayer.add(this.add.text(x0 - 16, y, FACTION_INFO.empire.symbol, { fontSize: '26px', color: FACTION_INFO.empire.colorHex }).setOrigin(1, 0.5)); + this.boardLayer.add(this.add.text(x0 + total * cellW + 16, y, FACTION_INFO.rebel.symbol, { fontSize: '26px', color: FACTION_INFO.rebel.colorHex }).setOrigin(0, 0.5)); + const label = gs.force === 0 ? 'THE FORCE IS BALANCED' + : `THE FORCE IS WITH THE ${(gs.force > 0 ? 'REBELS' : 'EMPIRE')}${Math.abs(gs.force) === FORCE_MAX ? ' — +1 RESOURCE' : ''}`; + this.boardLayer.add(this.add.text(cx, y + 34, label, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: gs.force === 0 ? C.muted : (gs.force > 0 ? FACTION_INFO.rebel.colorHex : FACTION_INFO.empire.colorHex), + }).setOrigin(0.5)); + } + + // ── human area ────────────────────────────────────────────────────────────── + renderHuman() { + const gs = this.gs; + const seat = this.humanSeat; + const p = gs.players[seat]; + const info = FACTION_INFO[p.faction]; + const legal = this.humanTurnLegal(); + + this.boardLayer.add(this.add.text(140, 878, info.label, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: info.colorHex, + }).setOrigin(0.5)); + // resources + this.boardLayer.add(this.add.text(140, 912, `▣ ${p.resources}`, { + fontFamily: 'Righteous', fontSize: '26px', color: '#e8c860', + }).setOrigin(0.5)); + this.boardLayer.add(this.add.text(140, 940, `deck ${p.deck.length} · discard ${p.discard.length}`, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: C.muted, + }).setOrigin(0.5)); + this.renderBasePips(70, 975, p, info); + + // base + if (p.base) { + const useBase = legal && legal.abilities.find((a) => a.zone === 'base'); + this.makeBaseCard(392, 800, p.faction, p.base, 300, 190, { + parent: this.boardLayer, hover: false, showText: true, + onClick: () => this.showInspectBase(p.faction, p.base), + }); + this._myBasePos = { x: 392, y: 800 }; + if (useBase) this.addUsePill(392, 902, () => this.beginAbility({ zone: 'base', uid: 'base' })); + } else { + this.boardLayer.add(this.add.text(392, 800, 'CHOOSE A NEW BASE…', { + fontFamily: 'Righteous', fontSize: '20px', color: C.goldHex, + }).setOrigin(0.5)); + } + + // capitals + p.capitals.forEach((e, i) => { + const x = 620 + i * 128; + const y = 800; + this._playPos.set(e.card.uid, { x, y }); + const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid); + const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0; + this.makeCard(x, y, e.card, 116, 162, { + parent: this.boardLayer, hover: false, damage: e.damage, + selected: this.squad.has(e.card.uid), committed: e.committed, + attackNow: entryAttack(gs, seat, e), + onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card), + }); + if (canUse) this.addUsePill(x, y + 96, () => this.beginAbility({ zone: 'capital', uid: e.card.uid })); + }); + + // units in play + const units = p.inPlay; + const uw = 122, uh = 170; + const pitch = Math.min(uw + 10, 620 / Math.max(1, units.length)); + const x0 = 1180 - ((units.length - 1) * pitch) / 2; + units.forEach((e, i) => { + const x = x0 + i * pitch; + const y = 795; + this._playPos.set(e.card.uid, { x, y }); + const canUse = legal && legal.abilities.some((a) => a.uid === e.card.uid); + const selectable = this.myTurnMode() && !e.committed && entryAttack(gs, seat, e) > 0; + this.makeCard(x, y, e.card, uw, uh, { + parent: this.boardLayer, hover: false, + selected: this.squad.has(e.card.uid), committed: e.committed, + attackNow: entryAttack(gs, seat, e), + onClick: () => selectable ? this.toggleSquad(e.card.uid) : this.showInspect(e.card), + }); + if (canUse) this.addUsePill(x, y + uh / 2 + 14, () => this.beginAbility({ zone: 'play', uid: e.card.uid })); + }); + if (!units.length && !p.capitals.length) { + this.boardLayer.add(this.add.text(1180, 795, 'cards you play land here', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a3c66', + }).setOrigin(0.5)); + } + + // squad readout + if (this.squad.size) { + const total = this.squadAttack(); + this.boardLayer.add(this.add.text(1180, 700, `⚔ ${total} committed — strike the enemy base or a marked bounty`, { + fontFamily: 'Righteous', fontSize: '18px', color: C.badHex, + }).setOrigin(0.5)); + } + } + + addUsePill(x, y, cb) { + const b = this.add.container(x, y); + const bg = this.add.graphics(); + bg.fillStyle(C.gold, 1); bg.fillRoundedRect(-34, -12, 68, 24, 12); + b.add(bg); + b.add(this.add.text(0, 0, 'USE', { fontFamily: 'Righteous', fontSize: '14px', color: '#0a0f1f' }).setOrigin(0.5)); + b.setSize(68, 24); + b.setInteractive({ useHandCursor: true }); + b.on('pointerdown', () => { if (!this.busy) cb(); }); + this.tweens.add({ targets: b, alpha: 0.65, duration: 520, yoyo: true, repeat: -1 }); + this.boardLayer.add(b); + } + + renderHand() { + const gs = this.gs; + const p = gs.players[this.humanSeat]; + if (!p.hand.length) return; + const cw = 148, chh = 206; + const pitch = Math.min(cw + 8, 1250 / Math.max(1, p.hand.length)); + let x = GAME_WIDTH / 2 - ((p.hand.length - 1) * pitch) / 2; + const y = 985; + for (const inst of p.hand) { + const playable = this.myTurnMode(); + const discardable = this.mode.type === 'oppDiscard' || (this.mode.type === 'target' && this.mode.decision?.op === 'exileCards' + && (this.mode.candidates || []).some((c) => c.zone === 'hand' && c.uid === inst.uid)); + this.makeCard(x, y, inst, cw, chh, { + parent: this.handLayer, showText: true, + highlightGold: discardable, + onClick: () => this.onHandClicked(inst), + }); + if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9); + x += pitch; + } + } + + // ── interaction helpers ───────────────────────────────────────────────────── + myTurnMode() { return this.mode.type === 'turn'; } + humanTurnLegal() { + if (!this.gs || !this.myTurnMode()) return null; + return legalActions(this.gs, this.humanSeat); + } + + squadAttack(vsRow = false) { + const p = this.gs.players[this.humanSeat]; + let total = 0; + for (const uid of this.squad) { + const e = p.inPlay.find((x) => x.card.uid === uid) || p.capitals.find((x) => x.card.uid === uid); + if (e) total += entryAttack(this.gs, this.humanSeat, e, { vsRow }); + } + return total; + } + + rowTargetReachable(card) { + if (!this.myTurnMode() || !this.squad.size) return false; + const def = cardDef(card); + const p = this.gs.players[this.humanSeat]; + if (def.faction === 'neutral' || def.faction === p.faction || def.type !== 'unit' || def.target == null) return false; + // capitals cannot join bounty hunts + for (const uid of this.squad) if (p.capitals.some((e) => e.card.uid === uid)) return false; + return this.squadAttack(true) >= def.target; + } + + toggleSquad(uid) { + if (this.squad.has(uid)) this.squad.delete(uid); + else this.squad.add(uid); + this.sfx(SFX.CARD_SHOW); + this.renderAll(); + } + + launchBaseAttack() { + if (!this.myTurnMode() || !this.squad.size) return; + const o = this.gs.players[1 - this.humanSeat]; + const order = o.capitals.slice() + .sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage)) + .map((e) => e.card.uid); + const uids = [...this.squad]; + this.squad.clear(); + this.sfx(SFX.SCIFI_LAUNCH); + this.applyDecision(this.mode.decision, { type: 'attackBase', uids, capitalOrder: order }); + } + + onRowClicked(card) { + const m = this.mode; + if (m.type === 'target') { + const ref = (m.candidates || []).find((c) => c.uid === card.uid); + if (ref) { this.applyDecision(m.decision, ref); return; } + } + if (this.myTurnMode()) { + if (this.squad.size && this.rowTargetReachable(card)) { + const uids = [...this.squad]; + this.squad.clear(); + this.sfx(SFX.SCIFI_LAUNCH); + this.applyDecision(this.mode.decision, { type: 'attackRow', targetUid: card.uid, uids }); + return; + } + const legal = this.humanTurnLegal(); + if (!this.squad.size && legal.buys.some((b) => b.uid === card.uid)) { + this.sfx(SFX.PURCHASE); + this.applyDecision(this.mode.decision, { type: 'buy', uid: card.uid }); + return; + } + } + this.showInspect(card); + } + + onOuterRimClicked() { + if (this.myTurnMode() && !this.squad.size) { + const legal = this.humanTurnLegal(); + if (legal.buys.some((b) => b.uid === 'outerrim')) { + this.sfx(SFX.PURCHASE); + this.applyDecision(this.mode.decision, { type: 'buy', uid: 'outerrim' }); + return; + } + } + const orp = this.gs.outerRim[this.gs.outerRim.length - 1]; + if (orp) this.showInspect(orp); + } + + onHandClicked(inst) { + const m = this.mode; + if (m.type === 'oppDiscard') { + this.sfx(SFX.CARD_PLACE); + this.applyDecision(m.decision, inst.uid); + return; + } + if (m.type === 'target' && m.decision?.op === 'exileCards') { + const ref = (m.candidates || []).find((c) => c.zone === 'hand' && c.uid === inst.uid); + if (ref) { this.applyDecision(m.decision, ref); return; } + } + if (this.myTurnMode()) { + this.sfx(SFX.CARD_PLACE); + this.applyDecision(m.decision, { type: 'play', uid: inst.uid }); + return; + } + this.showInspect(inst); + } + + beginAbility(target) { + const d = this.mode.decision; + const p = this.gs.players[this.humanSeat]; + const def = target.zone === 'base' ? baseDef(p.faction, p.base.id) + : cardDef((p.inPlay.find((e) => e.card.uid === target.uid) || p.capitals.find((e) => e.card.uid === target.uid)).card); + if (def.ability?.cost?.exileFromHand) { + // pick the cost card first + this.showHandPickModal('Choose a card to exile as the cost', (uid) => { + this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid, costUid: uid }); + }); + return; + } + this.sfx(SFX.SCIFI_REVEAL ?? SFX.CARD_SHOW); + this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid }); + } + + // ── pump: run engine until human input is required ───────────────────────── + pump() { + if (!this.gs || this.busy) return; + if (this.gs.over) { this.onGameOver(); return; } + const d = pendingDecision(this.gs); + if (!d) return; + if (d.seat !== this.humanSeat) { + this.setPrompt(`${this.opponents[0]?.name || 'Opponent'} is plotting…`); + this.busy = true; + this.time.delayedCall(nextThinkDelay(this.aiSkill), () => { + this.busy = false; + if (this.gs.over) { this.onGameOver(); return; } + const nd = pendingDecision(this.gs); + if (!nd || nd.seat === this.humanSeat) { this.pump(); return; } + const choice = decide(publicView(this.gs, nd.seat), nd, this.aiSkill); + this.applyDecision(nd, choice); + }); + return; + } + this.enterHumanMode(d); + } + + applyDecision(d, choice) { + try { + switch (d.kind) { + case 'turn': actTurn(this.gs, d.seat, choice); break; + case 'chooseBase': actChooseBase(this.gs, d.seat, choice); break; + case 'target': actChooseTarget(this.gs, d.seat, choice); break; + case 'oppDiscard': actOppDiscard(this.gs, d.seat, choice); break; + case 'spyChoice': actSpyChoice(this.gs, d.seat, choice); break; + case 'chooseOption': actChooseOption(this.gs, d.seat, choice); break; + default: break; + } + } catch (err) { + console.error('swdbg action rejected:', err); + this.mode = { type: 'idle' }; + this.renderAll(); + this.pump(); + return; + } + this.mode = { type: 'idle' }; + this.clearActionButtons(); + this.closeModal(); + this.playEvents(takeEvents(this.gs)); + } + + enterHumanMode(d) { + this.clearActionButtons(); + switch (d.kind) { + case 'turn': { + this.mode = { type: 'turn', decision: d }; + const legal = legalActions(this.gs, this.humanSeat); + const p = this.gs.players[this.humanSeat]; + if (p.hand.length) this.setPrompt('Your turn — play cards, buy, and attack'); + else if (legal.buys.length || legal.abilities.length || this.squad.size) this.setPrompt('Buy from the galaxy row, use abilities, or attack'); + else this.setPrompt('Attack with your forces or end your turn'); + if (p.hand.length) this.addActionButton('Play All', () => this.applyDecision(d, { type: 'playAll' })); + this.addActionButton('End Turn', () => { this.squad.clear(); this.applyDecision(d, { type: 'endTurn' }); }); + break; + } + case 'chooseBase': { + this.mode = { type: 'chooseBase', decision: d }; + this.showBaseChoiceModal(d); + break; + } + case 'target': { + this.mode = { type: 'target', decision: d, candidates: d.candidates }; + this.enterTargetMode(d); + break; + } + case 'oppDiscard': { + this.mode = { type: 'oppDiscard', decision: d }; + this.setPrompt('Enemy pressure! Choose a card to discard'); + break; + } + case 'spyChoice': { + this.mode = { type: 'spyChoice', decision: d }; + this.showSpyModal(d); + break; + } + case 'chooseOption': { + this.mode = { type: 'chooseOption', decision: d }; + this.showOptionModal(d); + break; + } + default: break; + } + this.renderAll(); + } + + enterTargetMode(d) { + switch (d.op) { + case 'discardRow': + this.setPrompt('Choose a galaxy-row card to discard — or Skip'); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + break; + case 'freePurchase': + this.setPrompt('Choose a card to take for free'); + break; + case 'destroyCapital': + this.setPrompt('Choose a capital ship to destroy'); + this.showCapitalTargetModal(d); + break; + case 'exileCards': + this.setPrompt('Exile a card from your hand or discard — or Skip'); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + this.addActionButton('From discard…', () => this.showDiscardPickModal(d)); + break; + case 'recoverDiscard': + this.showRecoverModal(d); + break; + case 'lookTop': + this.showPeekModal(d, 'Top of the galaxy deck', 'Leave on top', 'Discard it'); + break; + case 'revealTopDiscard': + this.showPeekModal(d, 'Revealed from the galaxy deck', 'Leave on top', 'Discard it'); + break; + default: + // Fallback: auto-pick the first candidate so nothing soft-locks. + this.applyDecision(d, d.candidates[0] || null); + break; + } + } + + // ── modals ────────────────────────────────────────────────────────────────── + modalRoot() { + this.closeModal(); + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62); + dim.setInteractive(); + root.add(dim); + this._modal = root; + return root; + } + closeModal() { if (this._modal) { this._modal.destroy(); this._modal = null; } for (const b of this._modalButtons || []) b.destroy(); this._modalButtons = []; } + modalButton(x, y, label, cb, opts = {}) { + const b = new Button(this, x, y, label, cb, { width: opts.width || 260, fontSize: opts.fontSize || 19, variant: opts.variant }); + b.setDepth(DEPTH.overlay + 1); + this._modalButtons = this._modalButtons || []; + this._modalButtons.push(b); + return b; + } + + showBaseChoiceModal(d) { + const root = this.modalRoot(); + const p = this.gs.players[this.humanSeat]; + root.add(this.add.text(GAME_WIDTH / 2, 300, 'YOUR BASE HAS FALLEN — CHOOSE THE NEXT', { + fontFamily: 'Righteous', fontSize: '30px', color: C.text, + }).setOrigin(0.5)); + const n = d.options.length; + const pitch = Math.min(360, 1500 / Math.max(1, n)); + const x0 = GAME_WIDTH / 2 - ((n - 1) * pitch) / 2; + d.options.forEach((id, i) => { + this.makeBaseCard(x0 + i * pitch, 560, p.faction, { id, damage: 0 }, 330, 210, { + parent: root, hover: false, showText: true, highlight: true, + onClick: () => this.applyDecision(d, id), + }); + }); + } + + showSpyModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + root.add(this.add.text(cx, cy - 120, 'DUROS SPY', { + fontFamily: 'Righteous', fontSize: '30px', color: C.badHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 70, 'Their agent forces a choice:', { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: C.text, + }).setOrigin(0.5)); + let y = cy; + if (d.options.includes('discard')) { + this.modalButton(cx, y, 'Discard a card from your hand', () => this.applyDecision(d, 'discard'), { width: 420 }); + y += 60; + } + if (d.options.includes('force')) { + this.modalButton(cx, y, 'Let them gain 1 Force', () => this.applyDecision(d, 'force'), { width: 420, variant: 'ghost' }); + } + } + + showOptionModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + root.add(this.add.text(cx, cy - 110, 'CHOOSE ONE', { + fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, + }).setOrigin(0.5)); + d.options.forEach((ab, i) => { + this.modalButton(cx, cy - 30 + i * 60, this.abilityLabel(ab), () => this.applyDecision(d, i), { width: 480 }); + }); + } + + abilityLabel(ab) { + switch (ab.op) { + case 'draw': return `Draw ${ab.n || 1} card${(ab.n || 1) > 1 ? 's' : ''}`; + case 'oppDiscard': return 'Opponent discards a card'; + case 'gainResources': return `Gain ${ab.n} resources`; + case 'gainForce': return `Gain ${ab.n} Force`; + case 'damageBase': return `Deal ${ab.n} damage to the enemy base`; + default: return ab.op; + } + } + + showPeekModal(d, title, keepLabel, discardLabel) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + root.add(this.add.text(cx, cy - 230, title, { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + if (d.peekId) this.makeCard(cx, cy - 20, { uid: -1, id: d.peekId }, 220, 306, { parent: root, hover: false, showText: true }); + this.modalButton(cx - 150, cy + 200, keepLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 260, variant: 'ghost' }); + this.modalButton(cx + 150, cy + 200, discardLabel, () => this.applyDecision(d, d.candidates.find((c) => c.v === 'discard')), { width: 260 }); + } + + showCapitalTargetModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 300, 'DESTROY A CAPITAL SHIP', { + fontFamily: 'Righteous', fontSize: '28px', color: C.badHex, + }).setOrigin(0.5)); + const o = this.gs.players[1 - this.humanSeat]; + const cards = d.candidates.map((ref) => { + if (ref.kind === 'capital') { + const e = o.capitals.find((x) => x.card.uid === ref.uid); + return { ref, inst: e.card, note: 'in play' }; + } + const inst = this.gs.galaxy.row.find((x) => x.uid === ref.uid); + return { ref, inst, note: 'galaxy row' }; + }); + const pitch = Math.min(240, 1400 / Math.max(1, cards.length)); + const x0 = cx - ((cards.length - 1) * pitch) / 2; + cards.forEach((c, i) => { + this.makeCard(x0 + i * pitch, 540, c.inst, 190, 264, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + root.add(this.add.text(x0 + i * pitch, 690, c.note, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted, + }).setOrigin(0.5)); + }); + } + + showRecoverModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 280, 'RETURN A CARD TO YOUR HAND', { + fontFamily: 'Righteous', fontSize: '28px', color: C.goldHex, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const cards = d.candidates.map((ref) => ({ ref, inst: p.discard.find((c) => c.uid === ref.uid) })).filter((c) => c.inst); + const pitch = Math.min(220, 1400 / Math.max(1, cards.length)); + const x0 = cx - ((cards.length - 1) * pitch) / 2; + cards.forEach((c, i) => { + this.makeCard(x0 + i * pitch, 540, c.inst, 180, 250, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + }); + } + + showDiscardPickModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 240, 'EXILE FROM YOUR DISCARD PILE', { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const cands = (d.candidates || []).filter((c) => c.zone === 'discard'); + const cards = cands.map((ref) => ({ ref, inst: p.discard.find((c) => c.uid === ref.uid) })).filter((c) => c.inst); + const cols = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(cards.length * 1.6)))); + const cw = 150, chh = 208; + cards.forEach((c, i) => { + const x = cx - ((Math.min(cols, cards.length) - 1) * (cw + 12)) / 2 + (i % cols) * (cw + 12); + const y = 430 + Math.floor(i / cols) * (chh + 14); + this.makeCard(x, y, c.inst, cw, chh, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + }); + this.modalButton(cx, GAME_HEIGHT - 110, 'Back', () => { this.closeModal(); }, { variant: 'ghost', width: 180 }); + } + + showHandPickModal(title, onPick) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 300, title.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + const p = this.gs.players[this.humanSeat]; + const pitch = Math.min(200, 1400 / Math.max(1, p.hand.length)); + const x0 = cx - ((p.hand.length - 1) * pitch) / 2; + p.hand.forEach((inst, i) => { + this.makeCard(x0 + i * pitch, 560, inst, 170, 236, { + parent: root, hover: false, showText: true, + onClick: () => { this.closeModal(); onPick(inst.uid); }, + }); + }); + this.modalButton(cx, 760, 'Cancel', () => { this.closeModal(); }, { variant: 'ghost', width: 180 }); + } + + showInspect(inst) { + if (this._modal || !inst || inst.hidden) return; + const root = this.modalRoot(); + this.makeCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, inst, 340, 470, { parent: root, hover: false, showText: true }); + const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive(); + zone.on('pointerdown', () => this.closeModal()); + root.add(zone); + root.bringToTop(zone); + } + + showInspectBase(faction, base) { + if (this._modal) return; + const root = this.modalRoot(); + this.makeBaseCard(GAME_WIDTH / 2, GAME_HEIGHT / 2, faction, base, 560, 340, { parent: root, hover: false, showText: true }); + const zone = this.add.zone(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT).setInteractive(); + zone.on('pointerdown', () => this.closeModal()); + root.add(zone); + root.bringToTop(zone); + } + + // ── prompt + action buttons ───────────────────────────────────────────────── + setPrompt(txt) { this.promptText.setText(txt || ''); } + addActionButton(label, cb) { + const i = this._actionButtons.length; + const b = new Button(this, 1700, 700 + i * 52, label, cb, { width: 190, fontSize: 18 }); + b.setDepth(DEPTH.ui); + this._actionButtons.push(b); + } + clearActionButtons() { + for (const b of this._actionButtons) b.destroy(); + this._actionButtons = []; + } + + // ── event playback ────────────────────────────────────────────────────────── + playEvents(events) { + this.busy = true; + this.setPrompt(''); + const queue = events.filter((e) => this.eventDelay(e) > 0); + const step = () => { + const e = queue.shift(); + if (!e) { + this.busy = false; + this.renderAll(); + this.pump(); + return; + } + this.eventFx(e); + this.time.delayedCall(this.eventDelay(e), step); + }; + this.renderAll(); + step(); + } + + eventDelay(e) { + const aiEvent = e.seat != null && e.seat !== this.humanSeat; + switch (e.type) { + case 'turnStart': return 460; + case 'play': return aiEvent ? 300 : 140; + case 'buy': return 420; + case 'bounty': return 640; + case 'attackBase': return 380; + case 'capitalDamage': return 300; + case 'capitalDestroyed': return 520; + case 'baseDamage': return 460; + case 'baseDestroyed': return 980; + case 'baseChosen': return 520; + case 'forceMove': return 240; + case 'forceBonus': return 240; + case 'reveal': return 700; + case 'exile': return 260; + case 'discard': return e.forced ? 340 : 0; + case 'recover': return 300; + case 'repair': return 300; + case 'rowDiscard': return 340; + case 'galaxyReshuffle': return 300; + default: return 0; + } + } + + eventFx(e) { + switch (e.type) { + case 'turnStart': + if (e.seat !== this.humanSeat) this.showBanner(`${this.seatName(e.seat)}'s turn`.toUpperCase()); + else this.showBanner('YOUR TURN'); + this.sfx(SFX.CARD_DEAL); + break; + case 'play': + this.sfx(SFX.CARD_PLACE); + break; + case 'buy': { + this.sfx(SFX.PURCHASE); + const from = this._rowPos.get(e.uid) || this._orpPos; + if (from) { + const tok = this.makeCardBack(from.x, from.y, 110, 154, this.fxLayer); + const to = e.seat === this.humanSeat ? { x: 140, y: 940 } : { x: GAME_WIDTH - 220, y: 90 }; + this.tweens.add({ targets: tok, x: to.x, y: to.y, scale: 0.3, alpha: 0.4, duration: 380, ease: 'Cubic.easeIn', onComplete: () => tok.destroy() }); + } + break; + } + case 'bounty': { + this.sfx(SFX.SCIFI_EXPLODE); + const at = this._rowPos.get(e.uid); + if (at) { + this.popText(at.x, at.y, '💥', '#ffffff', 44); + this.popText(at.x, at.y - 56, `${cardDef(e.id).name} defeated!`, C.goldHex, 20); + } + break; + } + case 'attackBase': + this.sfx(SFX.SCIFI_LAUNCH); + break; + case 'capitalDamage': { + const at = this._playPos.get(e.uid); + if (at) { this.popText(at.x, at.y - 20, `-${e.n}`, '#ff6b5e', 26); this.shakeAt(at.x, at.y); } + break; + } + case 'capitalDestroyed': { + this.sfx(SFX.SCIFI_EXPLODE); + const at = this._playPos.get(e.uid) || this._rowPos.get(e.uid); + if (at) this.popText(at.x, at.y, '💥', '#ffffff', 40); + this.popText(GAME_WIDTH / 2, 320, `${cardDef(e.id).name} destroyed`, '#ff6b5e', 22); + break; + } + case 'baseDamage': { + const pos = e.seat === this.humanSeat ? this._myBasePos : this._oppBasePos; + if (pos) { + this.popText(pos.x, pos.y - 40, `-${e.n}`, '#ff5348', 34); + const flash = this.add.rectangle(pos.x, pos.y, 310, 200, 0xd6604d, 0.35).setDepth(DEPTH.fx); + this.tweens.add({ targets: flash, alpha: 0, duration: 380, onComplete: () => flash.destroy() }); + this.sfx(SFX.BATTLESHIP_HIT); + } + break; + } + case 'baseDestroyed': { + this.sfx(SFX.SCIFI_EXPLODE); + const name = baseDef(this.gs.players[e.seat].faction, e.id).name; + this.showBanner(e.seat === this.humanSeat + ? `💥 ${name} HAS FALLEN (${e.lost}/${this.gs.meta.basesToWin})` + : `💥 YOU DESTROYED ${name.toUpperCase()} (${e.lost}/${this.gs.meta.basesToWin})`); + this.cameras.main.shake(240, 0.006); + break; + } + case 'baseChosen': + this.showBanner(`${this.seatName(e.seat)} regroups at ${baseDef(this.gs.players[e.seat].faction, e.id).name}`.toUpperCase()); + break; + case 'forceMove': { + this.sfx(SFX.SCIFI_PLINK ?? SFX.CARD_SHOW); + if (this._forceMarkerPos) this.shakeAt(this._forceMarkerPos.x, this._forceMarkerPos.y); + break; + } + case 'forceBonus': + this.popText(GAME_WIDTH / 2, 560, 'The Force provides: +1 ▣', '#9fd8f0', 20); + break; + case 'reveal': { + const card = this.makeCard(GAME_WIDTH / 2, 430, { uid: -1, id: e.id }, 180, 250, { parent: this.fxLayer, hover: false, showText: true }); + card.setScale(0.4).setAlpha(0); + this.tweens.add({ targets: card, scale: 1, alpha: 1, duration: 160, ease: 'Back.easeOut' }); + this.time.delayedCall(620, () => this.fxLayer.removeAll(true)); + break; + } + case 'exile': + this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} exiled`, '#b06bd8', 18); + break; + case 'discard': + if (e.forced) this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, `${this.seatName(e.seat)} discard${e.seat === this.humanSeat ? '' : 's'} ${cardDef(e.id).name}`, '#c7cede', 18); + break; + case 'recover': + this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} returns to hand`, '#3fbf6f', 18); + break; + case 'repair': + if (this._myBasePos && e.seat === this.humanSeat) this.popText(this._myBasePos.x, this._myBasePos.y - 40, `+${e.n} repaired`, '#3fbf6f', 24); + else if (this._oppBasePos) this.popText(this._oppBasePos.x, this._oppBasePos.y - 40, `+${e.n} repaired`, '#3fbf6f', 24); + break; + case 'rowDiscard': + this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.id).name} swept from the row`, '#c7cede', 18); + break; + case 'galaxyReshuffle': + this.sfx(SFX.CARD_SHUFFLE); + break; + default: break; + } + } + + popText(x, y, txt, color, size = 20) { + const t = this.add.text(x, y, txt, { + fontFamily: 'Righteous', fontSize: `${size}px`, color, stroke: '#05070f', strokeThickness: 4, + }).setOrigin(0.5).setDepth(DEPTH.fx); + this.tweens.add({ targets: t, y: y - 44, alpha: 0, duration: 950, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + } + + shakeAt(x, y) { + const g = this.add.graphics().setDepth(DEPTH.fx); + g.lineStyle(3, 0xffffff, 0.5); + g.strokeCircle(x, y, 24); + this.tweens.add({ targets: g, scale: 1.6, alpha: 0, duration: 300, onComplete: () => g.destroy() }); + } + + showBanner(text) { + const banner = this.add.text(GAME_WIDTH / 2, 300, text, { + fontFamily: 'Righteous', fontSize: '30px', color: C.text, + backgroundColor: '#05070fee', padding: { x: 26, y: 12 }, + }).setOrigin(0.5).setDepth(DEPTH.overlay); + this.tweens.add({ + targets: banner, y: 326, duration: 220, ease: 'Back.easeOut', + onComplete: () => this.time.delayedCall(950, () => + this.tweens.add({ targets: banner, alpha: 0, y: 300, duration: 220, onComplete: () => banner.destroy() })), + }); + } + + sfx(key) { try { playSound(this, key); } catch (_) { /* optional */ } } + + // ── game over ─────────────────────────────────────────────────────────────── + onGameOver() { + if (this._gameOverShown) return; + this._gameOverShown = true; + this.clearActionButtons(); + this.closeModal(); + this.setPrompt(''); + const gs = this.gs; + const won = gs.winner === this.humanSeat; + const human = gs.players[this.humanSeat]; + const ai = gs.players[1 - this.humanSeat]; + this.recordResult(won ? 'win' : 'loss', ai.lostBases, [human.lostBases]); + + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(DEPTH.overlay); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive()); + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(cx - 360, cy - 190, 720, 380, 16); + g.lineStyle(3, won ? C.gold : C.bad, 1); g.strokeRoundedRect(cx - 360, cy - 190, 720, 380, 16); + root.add(g); + root.add(this.add.text(cx, cy - 130, won ? '🏆 THE GALAXY IS YOURS' : 'YOUR LAST BASE HAS FALLEN', { + fontFamily: 'Righteous', fontSize: '36px', color: won ? C.goldHex : C.text, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 70, + won ? (human.faction === 'empire' ? 'Order has been restored to the galaxy.' : 'The spark has become a fire.') + : (human.faction === 'empire' ? 'The Rebellion celebrates in the streets.' : 'The dark times have begun.'), { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: C.muted, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy + 6, + `Enemy bases destroyed — you: ${ai.lostBases} · ${this.seatName(1 - this.humanSeat)}: ${human.lostBases}`, { + fontFamily: '"Julius Sans One"', fontSize: '21px', color: C.text, + }).setOrigin(0.5)); + new Button(this, cx - 115, cy + 120, 'Play Again', () => this.scene.restart(this._initData), + { width: 195, fontSize: 21 }).setDepth(DEPTH.overlay + 1); + new Button(this, cx + 115, cy + 120, 'Leave', () => this.scene.start('GameMenu'), + { width: 195, fontSize: 21, variant: 'ghost' }).setDepth(DEPTH.overlay + 1); + } + + async recordResult(result, score, opponentScores) { + if (this._recorded) return; + this._recorded = true; + try { + await api.post('/history/single-player', { slug: 'swdbg', score, opponentScores, result }); + } catch { /* best effort */ } + } +} diff --git a/src/games/swdbg/SWDBGLogic.js b/src/games/swdbg/SWDBGLogic.js new file mode 100644 index 0000000..959f364 --- /dev/null +++ b/src/games/swdbg/SWDBGLogic.js @@ -0,0 +1,872 @@ +// SWDBGLogic.js — pure (no Phaser) engine for Star Wars Deckbuilder, a faithful +// adaptation of Star Wars: The Deckbuilding Game (beginner mode: 5 bases each, +// first to destroy 3 enemy bases wins). +// +// Pull model, same shape as DungeonBossLogic: mutators (act*) validate against +// pendingDecision(state), typed events accumulate in state.events, and the +// scene/AI/verify scripts drain them with takeEvents(). There are no reaction +// windows in this game — the active player acts freely until endTurn — but +// abilities spawn sub-decisions (targets, opponent discards) via effectQueue. +// +// Force track: signed integer -3..+3. Positive = Rebel side, negative = Empire. +// "The Force is with you" = marker strictly on your side; pegged (±3) grants +// +1 resource at the start of your turn. Setup pegs it to the Rebel side. + +import { + FACTIONS, cardDef, baseDef, basesFor, getData, + buildGalaxyDeck, buildStarterDeck, buildOuterRim, makeInstance, resetUids, +} from './SWDBGData.js'; + +export const FORCE_MAX = 3; + +// ── RNG (mulberry32, seedable) ────────────────────────────────────────────── +export function makeRng(seed) { + let s = (seed >>> 0) || 1; + const fn = () => { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.int = (n) => Math.floor(fn() * n); + fn.pick = (arr) => arr[fn.int(arr.length)]; + fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + return fn; +} + +// ── Game creation ─────────────────────────────────────────────────────────── +// seat 0 = human (faction of their choice), seat 1 = AI. Empire moves first. +export function newGame({ humanFaction = 'rebel', seed = (Math.random() * 1e9) | 0 } = {}) { + if (!FACTIONS.includes(humanFaction)) throw new Error(`bad faction ${humanFaction}`); + resetUids(); + const rng = makeRng(seed); + const meta = getData().meta; + const players = []; + for (let seat = 0; seat < 2; seat++) { + const faction = seat === 0 ? humanFaction : (humanFaction === 'rebel' ? 'empire' : 'rebel'); + const bases = basesFor(faction); + const starting = bases.find((b) => b.starting); + players.push({ + seat, faction, + deck: rng.shuffle(buildStarterDeck(faction)), + hand: [], discard: [], + inPlay: [], // {card, tempAttack, used, committed} + capitals: [], // {card, damage, tempAttack, used, committed} + base: { id: starting.id, damage: 0 }, + baseUsed: false, + baseDeck: bases.filter((b) => !b.starting).map((b) => b.id), + lostBases: 0, + resources: 0, + }); + } + const state = { + seed, rng, meta, + force: FORCE_MAX * (meta.forceStartsWith === 'empire' ? -1 : 1), + galaxy: { deck: rng.shuffle(buildGalaxyDeck()), row: [], discard: [] }, + outerRim: buildOuterRim(), + exile: [], + players, + turnSeat: players.findIndex((p) => p.faction === 'empire'), + turnCount: 0, + effectQueue: [], + events: [], + over: false, winner: null, + }; + for (let i = 0; i < meta.galaxyRowSize; i++) refillRow(state); + for (const p of players) drawCards(state, p.seat, meta.handSize); + emit(state, { type: 'gameStart', factions: players.map((p) => p.faction) }); + beginTurn(state); + return state; +} + +function emit(state, ev) { state.events.push(ev); } +export function takeEvents(state) { const e = state.events; state.events = []; return e; } +export function isOver(state) { return state.over; } + +// ── Small helpers ─────────────────────────────────────────────────────────── +const P = (state, seat) => state.players[seat]; +const opp = (seat) => 1 - seat; +const forceSign = (faction) => (faction === 'rebel' ? 1 : -1); + +export function forceWith(state, seat) { + const p = P(state, seat); + return state.force * forceSign(p.faction) > 0; +} +export function forcePegged(state, seat) { + const p = P(state, seat); + return state.force * forceSign(p.faction) >= FORCE_MAX; +} + +function moveForce(state, seat, n) { + if (!n) return; + const dir = forceSign(P(state, seat).faction); + const before = state.force; + state.force = Math.max(-FORCE_MAX, Math.min(FORCE_MAX, state.force + dir * n)); + if (state.force !== before) emit(state, { type: 'forceMove', seat, from: before, to: state.force }); +} + +function drawCards(state, seat, n) { + const p = P(state, seat); + for (let i = 0; i < n; i++) { + if (!p.deck.length) { + if (!p.discard.length) return; + p.deck = state.rng.shuffle(p.discard); + p.discard = []; + emit(state, { type: 'reshuffle', seat }); + } + p.hand.push(p.deck.pop()); + emit(state, { type: 'draw', seat }); + } +} + +function refillRow(state) { + const g = state.galaxy; + if (g.row.length >= state.meta.galaxyRowSize) return; + if (!g.deck.length) { + if (!g.discard.length) return; // galaxy exhausted; row stays short + g.deck = state.rng.shuffle(g.discard); + g.discard = []; + emit(state, { type: 'galaxyReshuffle' }); + } + const card = g.deck.pop(); + g.row.push(card); + emit(state, { type: 'rowRefill', id: card.id, uid: card.uid }); +} + +function rowCard(state, uid) { return state.galaxy.row.find((c) => c.uid === uid) || null; } +function removeRowCard(state, uid) { + const i = state.galaxy.row.findIndex((c) => c.uid === uid); + if (i < 0) return null; + const [card] = state.galaxy.row.splice(i, 1); + refillRow(state); + return card; +} + +function findEntry(p, uid) { + return p.inPlay.find((e) => e.card.uid === uid) || p.capitals.find((e) => e.card.uid === uid) || null; +} + +// ── Attack math (shared by scene, AI, verify) ─────────────────────────────── +export function entryAttack(state, seat, entry, { vsRow = false } = {}) { + const p = P(state, seat); + const def = cardDef(entry.card); + let atk = (def.attack || 0) + (entry.tempAttack || 0); + const ab = def.ability; + const allMine = [...p.inPlay, ...p.capitals]; + if (ab && ab.timing === 'constant') { + if (ab.op === 'selfBoostWhileForce' && forceWith(state, seat)) atk += ab.amount; + if (ab.op === 'selfBoostWithTrait' + && allMine.some((e) => e !== entry && (cardDef(e.card).traits || []).includes(ab.trait))) atk += ab.amount; + if (ab.op === 'rowBonus' && vsRow) atk += ab.amount; + } + // traitBoost auras from my other cards in play. + for (const e of allMine) { + if (e === entry) continue; + const oab = cardDef(e.card).ability; + if (oab && oab.timing === 'constant' && oab.op === 'traitBoost' + && (def.traits || []).includes(oab.trait)) atk += oab.amount; + } + return atk; +} + +function capitalDiscount(state, seat) { + const p = P(state, seat); + let n = 0; + const b = p.base && baseDef(p.faction, p.base.id); + if (b?.ability?.timing === 'constant' && b.ability.op === 'capitalDiscount') n += b.ability.n; + return n; +} + +export function purchaseCost(state, seat, def) { + let cost = def.cost; + if (def.type === 'capital') cost -= capitalDiscount(state, seat); + return Math.max(0, cost); +} + +// ── Turn bookkeeping ──────────────────────────────────────────────────────── +function beginTurn(state) { + if (state.over) return; + const seat = state.turnSeat; + const p = P(state, seat); + state.turnCount++; + p.resources = 0; + p.baseUsed = false; + for (const e of p.capitals) { e.used = false; e.committed = false; e.tempAttack = 0; } + emit(state, { type: 'turnStart', seat, turn: state.turnCount }); + if (!p.base) { + state.effectQueue.push({ kind: 'chooseBase', seat }); + } + if (forcePegged(state, seat)) { + p.resources += 1; + emit(state, { type: 'forceBonus', seat }); + } + for (const e of p.capitals) { + const def = cardDef(e.card); + if (def.resources) { + p.resources += def.resources; + emit(state, { type: 'capitalIncome', seat, uid: e.card.uid, n: def.resources }); + } + } +} + +function endGame(state, winner, reason) { + state.over = true; + state.winner = winner; + state.effectQueue = []; + emit(state, { type: 'gameOver', winner, reason }); +} + +// ── Ability availability ──────────────────────────────────────────────────── +function abilityCandidates(state, seat, ab, ctx = {}) { + const p = P(state, seat); + const o = P(state, opp(seat)); + switch (ab.op) { + case 'discardRow': + return state.galaxy.row.map((c) => ({ kind: 'row', uid: c.uid })); + case 'destroyCapital': { + const out = o.capitals.map((e) => ({ kind: 'capital', seat: o.seat, uid: e.card.uid })); + for (const c of state.galaxy.row) if (cardDef(c).type === 'capital') out.push({ kind: 'rowCapital', uid: c.uid }); + return out; + } + case 'exileCards': { + const out = p.hand.filter((c) => c.uid !== ctx.excludeUid).map((c) => ({ kind: 'own', zone: 'hand', uid: c.uid })); + for (const c of p.discard) out.push({ kind: 'own', zone: 'discard', uid: c.uid }); + return out; + } + case 'recoverDiscard': + return p.discard + .filter((c) => ab.filter !== 'unique' || cardDef(c).unique) + .map((c) => ({ kind: 'own', zone: 'discard', uid: c.uid })); + case 'freePurchase': + return state.galaxy.row + .filter((c) => { + const def = cardDef(c); + if (ab.faction && def.faction !== ab.faction) return false; + if (ab.maxCost != null && def.cost > ab.maxCost) return false; + return true; + }) + .map((c) => ({ kind: 'row', uid: c.uid })); + default: + return null; // op needs no board target + } +} + +function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {}) { + const p = P(state, seat); + const o = P(state, opp(seat)); + if (ab.requiresForce && !forceWith(state, seat)) return false; + if (ab.cost?.exileFromHand) { + const spare = costPoolSize != null ? costPoolSize : p.hand.length; + if (spare < ab.cost.exileFromHand) return false; + } + switch (ab.op) { + case 'repairBase': return !!p.base && p.base.damage > 0; + case 'oppDiscard': return o.hand.length > 0; + case 'spyChoice': return o.hand.length > 0 || !forcePegged(state, seat); + case 'lookTop': return state.galaxy.deck.length > 0; + case 'revealTop': return state.galaxy.deck.length > 0; + case 'choose': return ab.options.some((sub) => abilityUsable(state, seat, sub, { ctx })); + case 'draw': case 'gainResources': case 'gainForce': case 'gainAttack': case 'damageBase': + return true; + default: { + const cands = abilityCandidates(state, seat, ab, ctx); + return cands === null || cands.length > 0; + } + } +} + +// ── Decisions ─────────────────────────────────────────────────────────────── +// Kinds: +// turn — active seat picks a main action (play/buy/ability/attack/end) +// chooseBase — pick your next base (start of turn after a loss) +// target — pick one candidate for a pending op (optional ⇒ null allowed) +// oppDiscard — the *victim* picks which of their cards to discard +// spyChoice — the victim picks 'discard' or 'force' (Duros Spy) +// chooseOption— pick a sub-ability index for a 'choose' op +// Drop/refresh queue entries whose context changed since they were queued +// (a second forced discard after the hand emptied, a second exile after the +// first consumed the last candidate, …) so a stale entry can never soft-lock. +function normalizeQueue(state) { + while (state.effectQueue.length) { + const q = state.effectQueue[0]; + if (q.kind === 'oppDiscard' && !P(state, q.seat).hand.length) { state.effectQueue.shift(); continue; } + if (q.dynamic === 'exileCards') { + q.candidates = abilityCandidates(state, q.seat, { op: 'exileCards' }); + if (!q.candidates.length) { state.effectQueue.shift(); continue; } + } + break; + } +} + +export function pendingDecision(state) { + if (state.over) return null; + normalizeQueue(state); + const q = state.effectQueue[0]; + if (q) { + if (q.kind === 'chooseBase') { + return { kind: 'chooseBase', seat: q.seat, options: P(state, q.seat).baseDeck.slice() }; + } + if (q.kind === 'oppDiscard') return { kind: 'oppDiscard', seat: q.seat }; + if (q.kind === 'spyChoice') return { kind: 'spyChoice', seat: q.seat, bySeat: q.bySeat, options: q.options }; + if (q.kind === 'chooseOption') return { kind: 'chooseOption', seat: q.seat, options: q.options }; + return { kind: 'target', seat: q.seat, op: q.op, candidates: q.candidates, optional: !!q.optional, peekId: q.peekId ?? null }; + } + return { kind: 'turn', seat: state.turnSeat }; +} + +function assertDecision(state, seat, kind) { + const d = pendingDecision(state); + if (!d || d.seat !== seat || d.kind !== kind) { + throw new Error(`illegal action: expected ${d ? `${d.kind} by seat ${d.seat}` : 'none'}, got ${kind} by ${seat}`); + } + return d; +} + +// All main-turn options for a seat — used by the AI, the scene, and verify. +export function legalActions(state, seat) { + const p = P(state, seat); + const o = P(state, opp(seat)); + const out = { plays: [], abilities: [], buys: [], rowTargets: [], baseAttackers: [], endTurn: true }; + if (state.over || state.turnSeat !== seat || state.effectQueue.length) return out; + + for (const c of p.hand) out.plays.push(c.uid); + + const tryAbility = (zone, uid, def, usedFlag) => { + const ab = def.ability; + if (!ab || usedFlag) return; + if (ab.timing !== 'action') return; + if (!abilityUsable(state, seat, ab)) return; + out.abilities.push({ zone, uid, op: ab.op }); + }; + for (const e of p.inPlay) tryAbility('play', e.card.uid, cardDef(e.card), e.used); + for (const e of p.capitals) tryAbility('capital', e.card.uid, cardDef(e.card), e.used); + if (p.base) { + const b = baseDef(p.faction, p.base.id); + if (b.ability?.timing === 'action' && !p.baseUsed && abilityUsable(state, seat, b.ability)) { + out.abilities.push({ zone: 'base', uid: 'base', op: b.ability.op }); + } + } + + for (const c of state.galaxy.row) { + const def = cardDef(c); + if (def.faction !== 'neutral' && def.faction !== p.faction) continue; + const cost = purchaseCost(state, seat, def); + if (cost <= p.resources) out.buys.push({ uid: c.uid, cost }); + } + if (state.outerRim.length) { + const orp = getData().json.outerRimPilot; + if (orp.cost <= p.resources) out.buys.push({ uid: 'outerrim', cost: orp.cost }); + } + + // Bounty hunting / sabotage: enemy-faction UNITS in the row only. + const freeUnits = p.inPlay.filter((e) => !e.committed); + const rowPower = freeUnits.reduce((a, e) => a + entryAttack(state, seat, e, { vsRow: true }), 0); + for (const c of state.galaxy.row) { + const def = cardDef(c); + if (def.faction === 'neutral' || def.faction === p.faction || def.type !== 'unit') continue; + if (def.target != null && rowPower >= def.target) out.rowTargets.push({ uid: c.uid, target: def.target }); + } + + const attackers = [...p.inPlay, ...p.capitals].filter((e) => !e.committed && entryAttack(state, seat, e) > 0); + if (attackers.length && (o.base || o.capitals.length)) { + out.baseAttackers = attackers.map((e) => e.card.uid); + } + return out; +} + +// ── Effect resolution ─────────────────────────────────────────────────────── +function queueOp(state, seat, ab, ctx = {}) { + switch (ab.op) { + case 'draw': { + let n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + if (ab.nBase && P(state, seat).base?.id === ab.nBase.base) n = ab.nBase.n; + drawCards(state, seat, n); + return; + } + case 'gainResources': { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + P(state, seat).resources += n; + emit(state, { type: 'resources', seat, n }); + return; + } + case 'gainForce': moveForce(state, seat, ab.n); return; + case 'gainAttack': { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + if (ctx.entry) { ctx.entry.tempAttack += n; emit(state, { type: 'attackBoost', seat, uid: ctx.entry.card.uid, n }); } + return; + } + case 'damageBase': { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; + dealBaseDamage(state, seat, n, { direct: true }); + return; + } + case 'repairBase': { + const p = P(state, seat); + if (p.base) { + const healed = Math.min(ab.n, p.base.damage); + p.base.damage -= healed; + emit(state, { type: 'repair', seat, n: healed }); + } + return; + } + case 'oppDiscard': { + const victim = opp(seat); + const random = ab.randomForce && forceWith(state, seat); + for (let i = 0; i < (ab.n || 1); i++) { + if (random) { + const o = P(state, victim); + if (!o.hand.length) break; + const c = state.rng.pick(o.hand); + o.hand.splice(o.hand.indexOf(c), 1); + o.discard.push(c); + emit(state, { type: 'discard', seat: victim, id: c.id, forced: true, random: true }); + } else if (P(state, victim).hand.length) { + state.effectQueue.push({ kind: 'oppDiscard', seat: victim }); + } + } + return; + } + case 'spyChoice': { + const victim = opp(seat); + const options = []; + if (P(state, victim).hand.length) options.push('discard'); + if (!forcePegged(state, seat)) options.push('force'); + if (!options.length) return; + if (options.length === 1 && options[0] === 'force') { moveForce(state, seat, 1); return; } + state.effectQueue.push({ kind: 'spyChoice', seat: victim, bySeat: seat, options }); + return; + } + case 'lookTop': { + const top = state.galaxy.deck[state.galaxy.deck.length - 1]; + if (!top) return; + state.effectQueue.push({ + kind: 'target', seat, op: 'lookTop', peekId: top.id, optional: false, + candidates: [{ kind: 'opt', v: 'keep' }, { kind: 'opt', v: 'discard' }], + }); + return; + } + case 'revealTop': { + const top = state.galaxy.deck[state.galaxy.deck.length - 1]; + if (!top) return; + const def = cardDef(top); + emit(state, { type: 'reveal', seat, id: top.id }); + const mine = P(state, seat).faction; + if (def.faction === mine) { + P(state, seat).resources += 1; + emit(state, { type: 'resources', seat, n: 1 }); + } else if (def.faction !== 'neutral') { + state.effectQueue.push({ + kind: 'target', seat, op: 'revealTopDiscard', peekId: top.id, optional: false, + candidates: [{ kind: 'opt', v: 'keep' }, { kind: 'opt', v: 'discard' }], + }); + } + return; + } + case 'exileCards': { + for (let i = 0; i < (ab.n || 1); i++) { + state.effectQueue.push({ + kind: 'target', seat, op: 'exileCards', optional: ab.optional !== false, + dynamic: 'exileCards', + candidates: abilityCandidates(state, seat, { op: 'exileCards' }), + }); + } + return; + } + case 'choose': { + const usable = ab.options.filter((sub) => abilityUsable(state, seat, sub)); + if (!usable.length) return; + if (usable.length === 1) { queueOp(state, seat, usable[0], ctx); return; } + state.effectQueue.push({ kind: 'chooseOption', seat, options: usable, ctx }); + return; + } + case 'discardRow': case 'destroyCapital': case 'recoverDiscard': case 'freePurchase': { + const candidates = abilityCandidates(state, seat, ab); + if (!candidates.length) return; + state.effectQueue.push({ + kind: 'target', seat, op: ab.op, ab, optional: ab.op === 'discardRow', + candidates, + }); + return; + } + default: + throw new Error(`unsupported op ${ab.op}`); + } +} + +// Direct base damage (card abilities): ignores capital ships. +function dealBaseDamage(state, seat, n, { direct = false } = {}) { + const o = P(state, opp(seat)); + if (!o.base || n <= 0) return; + o.base.damage += n; + emit(state, { type: 'baseDamage', seat: o.seat, n, direct }); + checkBaseDestroyed(state, seat); +} + +function checkBaseDestroyed(state, attackerSeat) { + const o = P(state, opp(attackerSeat)); + if (!o.base) return; + const def = baseDef(o.faction, o.base.id); + if (o.base.damage < def.hp) return; + o.lostBases++; + emit(state, { type: 'baseDestroyed', seat: o.seat, id: o.base.id, lost: o.lostBases }); + o.base = null; + o.baseUsed = false; + if (o.lostBases >= state.meta.basesToWin) endGame(state, attackerSeat, 'bases'); +} + +function fireOnBounty(state, seat) { + const p = P(state, seat); + for (const e of [...p.inPlay, ...p.capitals]) { + const ab = cardDef(e.card).ability; + if (ab && ab.timing === 'onBounty') queueOp(state, seat, ab, { entry: e }); + } +} + +// ── Mutators ──────────────────────────────────────────────────────────────── +export function actTurn(state, seat, action) { + assertDecision(state, seat, 'turn'); + const p = P(state, seat); + switch (action?.type) { + case 'play': { + const i = p.hand.findIndex((c) => c.uid === action.uid); + if (i < 0) throw new Error('card not in hand'); + playCard(state, seat, p.hand.splice(i, 1)[0]); + return; + } + case 'playAll': { + while (p.hand.length && !state.effectQueue.length) playCard(state, seat, p.hand.shift()); + return; + } + case 'ability': return useAbility(state, seat, action); + case 'buy': return buyCard(state, seat, action.uid); + case 'attackRow': return attackRow(state, seat, action); + case 'attackBase': return attackBase(state, seat, action); + case 'endTurn': return endTurn(state, seat); + default: throw new Error(`unknown turn action ${action?.type}`); + } +} + +function playCard(state, seat, card) { + const p = P(state, seat); + const def = cardDef(card); + const entry = { card, tempAttack: 0, used: false, committed: false }; + if (def.type === 'capital') { entry.damage = 0; p.capitals.push(entry); } + else p.inPlay.push(entry); + emit(state, { type: 'play', seat, id: card.id, uid: card.uid, capital: def.type === 'capital' }); + if (def.resources) { p.resources += def.resources; emit(state, { type: 'resources', seat, n: def.resources }); } + if (def.force) moveForce(state, seat, def.force); + // Base auras that trigger on play (Yavin IV). + if (p.base && def.type === 'unit') { + const b = baseDef(p.faction, p.base.id); + if (b.ability?.timing === 'constant' && b.ability.op === 'onPlayTraitAttack' + && (def.traits || []).includes(b.ability.trait)) { + entry.tempAttack += b.ability.amount; + emit(state, { type: 'attackBoost', seat, uid: card.uid, n: b.ability.amount, from: 'base' }); + } + } + if (def.ability?.timing === 'onPlay') queueOp(state, seat, def.ability, { entry }); +} + +function useAbility(state, seat, action) { + const p = P(state, seat); + let ab, entry = null; + if (action.zone === 'base') { + if (!p.base) throw new Error('no base'); + const b = baseDef(p.faction, p.base.id); + ab = b.ability; + if (!ab || ab.timing !== 'action' || p.baseUsed) throw new Error('base ability unavailable'); + } else { + entry = findEntry(p, action.uid); + if (!entry) throw new Error('card not in play'); + ab = cardDef(entry.card).ability; + if (!ab || ab.timing !== 'action' || entry.used) throw new Error('ability unavailable'); + } + if (!abilityUsable(state, seat, ab)) throw new Error('ability not usable now'); + + // Pay costs first (rulebook: exileSelf cards keep their printed values — + // those were already collected at play time). + if (ab.cost?.exileFromHand) { + const c = p.hand.find((x) => x.uid === action.costUid); + if (!c) throw new Error('exileFromHand cost needs a hand card (costUid)'); + p.hand.splice(p.hand.indexOf(c), 1); + state.exile.push(c); + emit(state, { type: 'exile', seat, id: c.id, from: 'hand', cost: true }); + } + if (ab.cost?.exileSelf && entry) { + const pool = cardDef(entry.card).type === 'capital' ? p.capitals : p.inPlay; + pool.splice(pool.indexOf(entry), 1); + state.exile.push(entry.card); + emit(state, { type: 'exile', seat, id: entry.card.id, from: 'play', cost: true }); + } + if (action.zone === 'base') p.baseUsed = true; + else entry.used = true; + emit(state, { type: 'ability', seat, id: action.zone === 'base' ? p.base?.id ?? 'base' : entry.card.id, op: ab.op }); + queueOp(state, seat, ab, { entry }); +} + +function buyCard(state, seat, uid) { + const p = P(state, seat); + let card, def; + if (uid === 'outerrim') { + if (!state.outerRim.length) throw new Error('Outer Rim Pilots exhausted'); + def = getData().json.outerRimPilot; + if (p.resources < def.cost) throw new Error('cannot afford'); + p.resources -= def.cost; + card = state.outerRim.pop(); + } else { + card = rowCard(state, uid); + if (!card) throw new Error('card not in row'); + def = cardDef(card); + if (def.faction !== 'neutral' && def.faction !== p.faction) throw new Error('enemy faction card'); + const cost = purchaseCost(state, seat, def); + if (p.resources < cost) throw new Error('cannot afford'); + p.resources -= cost; + removeRowCard(state, uid); + } + p.discard.push(card); + emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: def.cost }); +} + +function attackRow(state, seat, action) { + const p = P(state, seat); + const target = rowCard(state, action.targetUid); + if (!target) throw new Error('target not in row'); + const tdef = cardDef(target); + if (tdef.faction === 'neutral' || tdef.faction === p.faction || tdef.type !== 'unit' || tdef.target == null) { + throw new Error('not a legal bounty target'); + } + const entries = (action.uids || []).map((uid) => { + const e = p.inPlay.find((x) => x.card.uid === uid); + if (!e) throw new Error('attacker not among your units in play'); + if (e.committed) throw new Error('attacker already committed'); + return e; + }); + if (!entries.length) throw new Error('no attackers'); + const total = entries.reduce((a, e) => a + entryAttack(state, seat, e, { vsRow: true }), 0); + if (total < tdef.target) throw new Error(`attack ${total} < target ${tdef.target}`); + for (const e of entries) e.committed = true; + removeRowCard(state, target.uid); + state.galaxy.discard.push(target); + emit(state, { type: 'bounty', seat, id: target.id, uid: target.uid, total, target: tdef.target }); + const r = tdef.reward || {}; + if (r.resources) { p.resources += r.resources; emit(state, { type: 'resources', seat, n: r.resources, reward: true }); } + if (r.force) moveForce(state, seat, r.force); + if (r.draw) drawCards(state, seat, r.draw); + fireOnBounty(state, seat); +} + +function attackBase(state, seat, action) { + const p = P(state, seat); + const o = P(state, opp(seat)); + const entries = (action.uids || []).map((uid) => { + const e = findEntry(p, uid); + if (!e) throw new Error('attacker not in play'); + if (e.committed) throw new Error('attacker already committed'); + return e; + }); + if (!entries.length) throw new Error('no attackers'); + let pool = entries.reduce((a, e) => a + entryAttack(state, seat, e), 0); + if (pool <= 0) throw new Error('no attack power'); + for (const e of entries) e.committed = true; + emit(state, { type: 'attackBase', seat, total: pool }); + + // Capital ships absorb first. Attacker may order them via capitalOrder. + const order = []; + for (const uid of action.capitalOrder || []) { + const e = o.capitals.find((x) => x.card.uid === uid); + if (e && !order.includes(e)) order.push(e); + } + for (const e of o.capitals) if (!order.includes(e)) order.push(e); + for (const e of order) { + if (pool <= 0) break; + const hp = cardDef(e.card).hp; + const dealt = Math.min(pool, hp - e.damage); + e.damage += dealt; + pool -= dealt; + emit(state, { type: 'capitalDamage', seat: o.seat, uid: e.card.uid, n: dealt, hp, damage: e.damage }); + if (e.damage >= hp) { + o.capitals.splice(o.capitals.indexOf(e), 1); + o.discard.push(e.card); + emit(state, { type: 'capitalDestroyed', seat: o.seat, id: e.card.id, uid: e.card.uid }); + } + } + if (pool > 0 && o.capitals.length === 0 && o.base) { + o.base.damage += pool; + emit(state, { type: 'baseDamage', seat: o.seat, n: pool, direct: false }); + checkBaseDestroyed(state, seat); + } +} + +function endTurn(state, seat) { + const p = P(state, seat); + for (const e of p.inPlay) p.discard.push(e.card); + p.inPlay = []; + for (const c of p.hand) p.discard.push(c); + p.hand = []; + p.resources = 0; + drawCards(state, seat, state.meta.handSize); + emit(state, { type: 'turnEnd', seat }); + state.turnSeat = opp(seat); + beginTurn(state); +} + +export function actChooseBase(state, seat, baseId) { + assertDecision(state, seat, 'chooseBase'); + const p = P(state, seat); + const i = p.baseDeck.indexOf(baseId); + if (i < 0) throw new Error(`base ${baseId} not available`); + p.baseDeck.splice(i, 1); + p.base = { id: baseId, damage: 0 }; + p.baseUsed = false; + state.effectQueue.shift(); + emit(state, { type: 'baseChosen', seat, id: baseId }); + const b = baseDef(p.faction, baseId); + if (b.ability?.timing === 'onReveal') queueOp(state, seat, b.ability, {}); +} + +export function actChooseTarget(state, seat, ref) { + const d = assertDecision(state, seat, 'target'); + const q = state.effectQueue[0]; + if (!ref) { + if (!d.optional) throw new Error('target required'); + state.effectQueue.shift(); + emit(state, { type: 'skip', seat, op: q.op }); + return; + } + const match = q.candidates.find((c) => c.kind === ref.kind && c.uid === ref.uid && c.v === ref.v && c.zone === ref.zone && c.seat === ref.seat); + if (!match) throw new Error('target not among candidates'); + state.effectQueue.shift(); + applyTarget(state, seat, q, match); +} + +function applyTarget(state, seat, q, ref) { + const p = P(state, seat); + switch (q.op) { + case 'discardRow': { + const card = removeRowCard(state, ref.uid); + if (card) { state.galaxy.discard.push(card); emit(state, { type: 'rowDiscard', seat, id: card.id }); } + return; + } + case 'destroyCapital': { + if (ref.kind === 'rowCapital') { + const card = removeRowCard(state, ref.uid); + if (card) { state.galaxy.discard.push(card); emit(state, { type: 'capitalDestroyed', row: true, id: card.id, uid: card.uid }); } + } else { + const o = P(state, ref.seat); + const i = o.capitals.findIndex((e) => e.card.uid === ref.uid); + if (i >= 0) { + const [e] = o.capitals.splice(i, 1); + o.discard.push(e.card); + emit(state, { type: 'capitalDestroyed', seat: o.seat, id: e.card.id, uid: e.card.uid }); + } + } + return; + } + case 'exileCards': { + const zone = ref.zone === 'hand' ? p.hand : p.discard; + const i = zone.findIndex((c) => c.uid === ref.uid); + if (i >= 0) { + const [c] = zone.splice(i, 1); + state.exile.push(c); + emit(state, { type: 'exile', seat, id: c.id, from: ref.zone }); + } + return; + } + case 'recoverDiscard': { + const i = p.discard.findIndex((c) => c.uid === ref.uid); + if (i >= 0) { + const [c] = p.discard.splice(i, 1); + p.hand.push(c); + emit(state, { type: 'recover', seat, id: c.id }); + } + return; + } + case 'freePurchase': { + const card = removeRowCard(state, ref.uid); + if (!card) return; + const topdeck = q.ab?.forceTopdeck && forceWith(state, seat); + if (topdeck) p.deck.push(card); else p.discard.push(card); + emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: 0, free: true, topdeck: !!topdeck }); + return; + } + case 'lookTop': { + if (ref.v === 'discard') { + const c = state.galaxy.deck.pop(); + if (c) { state.galaxy.discard.push(c); emit(state, { type: 'galaxyTopDiscard', seat, id: c.id }); } + } else { + emit(state, { type: 'galaxyTopKept', seat }); + } + return; + } + case 'revealTopDiscard': { + if (ref.v === 'discard') { + const c = state.galaxy.deck.pop(); + if (c) { state.galaxy.discard.push(c); emit(state, { type: 'galaxyTopDiscard', seat, id: c.id }); } + } else { + emit(state, { type: 'galaxyTopKept', seat }); + } + return; + } + default: + throw new Error(`unhandled target op ${q.op}`); + } +} + +export function actOppDiscard(state, seat, uid) { + assertDecision(state, seat, 'oppDiscard'); + const p = P(state, seat); + const i = p.hand.findIndex((c) => c.uid === uid); + if (i < 0) throw new Error('card not in hand'); + const [c] = p.hand.splice(i, 1); + p.discard.push(c); + state.effectQueue.shift(); + emit(state, { type: 'discard', seat, id: c.id, forced: true }); +} + +export function actSpyChoice(state, seat, choice) { + const d = assertDecision(state, seat, 'spyChoice'); + if (!d.options.includes(choice)) throw new Error(`spyChoice must be one of ${d.options}`); + const q = state.effectQueue.shift(); + if (choice === 'force') { + moveForce(state, q.bySeat, 1); + } else { + // Victim still picks which card — queue their discard. + state.effectQueue.unshift({ kind: 'oppDiscard', seat }); + } +} + +export function actChooseOption(state, seat, idx) { + const d = assertDecision(state, seat, 'chooseOption'); + if (idx == null || idx < 0 || idx >= d.options.length) throw new Error('bad option index'); + const q = state.effectQueue.shift(); + queueOp(state, seat, q.options[idx], q.ctx || {}); +} + +// ── Public (redacted) view ────────────────────────────────────────────────── +// Own hand visible; opponent hand, both decks, the galaxy deck and the Outer +// Rim stack become stubs (lengths stay honest). Discards and exile are public. +export function publicView(state, seat) { + const stub = () => ({ uid: -1, id: null, hidden: true }); + return { + seed: state.seed, meta: state.meta, force: state.force, + turnSeat: state.turnSeat, turnCount: state.turnCount, + effectQueue: state.effectQueue.map((q) => ({ kind: q.kind, seat: q.seat })), + galaxy: { + deck: state.galaxy.deck.map(stub), + row: state.galaxy.row, + discard: state.galaxy.discard, + }, + outerRim: state.outerRim.map(stub), + exile: state.exile, + players: state.players.map((p) => (p.seat === seat ? p : { + seat: p.seat, faction: p.faction, + deck: p.deck.map(stub), + hand: p.hand.map(stub), + discard: p.discard, + inPlay: p.inPlay, capitals: p.capitals, + base: p.base, baseUsed: p.baseUsed, baseDeck: p.baseDeck, + lostBases: p.lostBases, resources: p.resources, + })), + over: state.over, winner: state.winner, + }; +} diff --git a/src/games/swdbg/sprites.md b/src/games/swdbg/sprites.md new file mode 100644 index 0000000..cc7f7f7 --- /dev/null +++ b/src/games/swdbg/sprites.md @@ -0,0 +1,171 @@ +# Star Wars Deckbuilder — Sprite / Art Spec + +Everything in SWDBG renders procedurally out of the box (faction-colored frame, +cost badge, stats, name plate, rules text), so the game is fully playable with +**no** art. This document lists the optional sprite sheets you can drop in to +replace the placeholder art windows, with exact dimensions, layout, and +frame-by-frame maps. + +All art is wired through **`public/data/swdbg-artwork.json`**. The scene checks +whether each sheet's texture is loaded; if it is, it uses your frame, otherwise +it falls back to procedural drawing. You never need to touch code — just add a +PNG, set its `path` in that JSON (it's currently empty for both sheets), and +reload. + +Frame numbering is **row-major, 0-based**: frame 0 is top-left, count +left-to-right then down to the next row. + +Only paint the illustration — the card frame, border, faction edge color, +unique-card gold inset, cost/stat badges, name plate, and rules text are all +drawn procedurally on top of your art. Art is scaled to **fit** the art window +(no cropping), so match the aspect ratio below for edge-to-edge fill; other +aspects letterbox inside the window. + +--- + +## 1. Card art sheet — `swdbg-cards-art.png` + +| | | +|---|---| +| **Path** | `public/assets/images/swdbg-cards-art.png` | +| **Frame size** | **300 × 230 px** (aspect ≈ 1.30 : 1, matches the card art window) | +| **Sheet size** | **8 columns × 7 rows = 2400 × 1610 px** holds all 55 frames (1 spare) | +| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) | +| **JSON** | `cardSheet` (set `path`) + `cards` map (already filled in for all 55 ids) | + +The art window sits just below the name plate on every unit/ship/character +card. It's shown at roughly 100×77 px on a resting hand card and up to +~280×215 px in the inspect/reveal zoom, so design for readability at the +smaller size — bold silhouettes and clear color-blocking over fine detail. + +### Frame map + +Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim. + +| Frame | id | Name | Faction | Notes | +|---:|---|---|---|---| +| 0 | `allianceshuttle` | Alliance Shuttle | R | | +| 1 | `rebeltrooper` | Rebel Trooper | R | | +| 2 | `templeguardian` | Temple Guardian | R | | +| 3 | `imperialshuttle` | Imperial Shuttle | E | | +| 4 | `stormtrooper` | Stormtrooper | E | | +| 5 | `inquisitor` | Inquisitor | E | | +| 6 | `outerrimpilot` | Outer Rim Pilot | N | shared neutral card | +| 7 | `tiefighter` | TIE Fighter | E | | +| 8 | `scouttrooper` | Scout Trooper | E | | +| 9 | `deathtrooper` | Death Trooper | E | | +| 10 | `tiebomber` | TIE Bomber | E | | +| 11 | `tieinterceptor` | TIE Interceptor | E | | +| 12 | `atst` | AT-ST | E | | +| 13 | `atat` | AT-AT | E | | +| 14 | `generalveers` | General Veers | E | unique | +| 15 | `grandmofftarkin` | Grand Moff Tarkin | E | unique | +| 16 | `directorkrennic` | Director Krennic | E | unique | +| 17 | `darthvader` | Darth Vader | E | unique | +| 18 | `emperorpalpatine` | Emperor Palpatine | E | unique | +| 19 | `gozanticruiser` | Gozanti Cruiser | E | | +| 20 | `imperialcarrier` | Imperial Carrier | E | | +| 21 | `stardestroyer` | Star Destroyer | E | | +| 22 | `rebelpathfinder` | Rebel Pathfinder | R | | +| 23 | `ywing` | Y-Wing | R | | +| 24 | `snowspeeder` | Snowspeeder | R | | +| 25 | `durosspy` | Duros Spy | R | | +| 26 | `xwing` | X-Wing | R | | +| 27 | `rebelcommando` | Rebel Commando | R | | +| 28 | `cassianandor` | Cassian Andor | R | unique | +| 29 | `k2so` | K-2SO | R | unique | +| 30 | `chirrutimwe` | Chirrut Îmwe | R | unique | +| 31 | `jynerso` | Jyn Erso | R | unique | +| 32 | `chewbacca` | Chewbacca | R | unique | +| 33 | `hansolo` | Han Solo | R | unique | +| 34 | `princessleia` | Princess Leia | R | unique | +| 35 | `lukeskywalker` | Luke Skywalker | R | unique | +| 36 | `blockaderunner` | Blockade Runner | R | | +| 37 | `hammerheadcorvette` | Hammerhead Corvette | R | | +| 38 | `medicalfrigate` | Medical Frigate | R | | +| 39 | `moncalamaricruiser` | Mon Calamari Cruiser | R | | +| 40 | `millenniumfalcon` | Millennium Falcon | R | unique | +| 41 | `z95headhunter` | Z-95 Headhunter | N | | +| 42 | `jawascavenger` | Jawa Scavenger | N | | +| 43 | `keldormystic` | Kel Dor Mystic | N | | +| 44 | `rodiangunslinger` | Rodian Gunslinger | N | | +| 45 | `hwk290` | HWK-290 | N | | +| 46 | `twileksmuggler` | Twi'lek Smuggler | N | | +| 47 | `fangfighter` | Fang Fighter | N | | +| 48 | `quarrenmercenary` | Quarren Mercenary | N | | +| 49 | `bossk` | Bossk | N | unique bounty hunter | +| 50 | `ig88` | IG-88 | N | unique bounty hunter | +| 51 | `bobafett` | Boba Fett | N | unique bounty hunter | +| 52 | `jabbathehutt` | Jabba the Hutt | N | unique | +| 53 | `crocruiser` | Crolute Cruiser | N | | +| 54 | `khetanna` | Khetanna (Jabba's Sail Barge) | N | unique | + +> Priority: uniques (14–18, 28–35, 40, 49–52, 54) and starters/commons that +> show up every game (0–5, 7–9, 22–24, 26–27) read best painted first. + +--- + +## 2. Base art sheet — `swdbg-bases-art.png` + +| | | +|---|---| +| **Path** | `public/assets/images/swdbg-bases-art.png` | +| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the base art window) | +| **Sheet size** | **5 columns × 2 rows = 1100 × 600 px** holds all 10 frames | +| **Status** | ⛔ Procedural until you create it (then set `baseSheet.path` in the JSON) | +| **JSON** | `baseSheet` (set `path`) + `bases` map (already filled in for all 10 ids) | + +Bases are the large planet/location cards planted on each player's side of the +board (shown at ~300×190 landscape during play — note the base *card* itself +is wider-than-tall on screen, but the art window inside it is a portrait +crop, so compose art with a strong vertical focal point that still reads when +letterboxed). Also shown up to 560×340 in the win/lose reveal. + +### Frame map + +| Frame | id | Name | Notes | +|---:|---|---|---| +| 0 | `dantooine` | Dantooine | Rebel base, grassy plains | +| 1 | `hoth` | Hoth | Rebel base, ice world | +| 2 | `sullust` | Sullust | Rebel base, volcanic | +| 3 | `moncala` | Mon Cala | Rebel base, ocean world | +| 4 | `yavin4` | Yavin 4 | Rebel base, jungle moon/temple | +| 5 | `lothal` | Lothal | Imperial base, plains/wheat | +| 6 | `corellia` | Corellia | Imperial base, shipyards | +| 7 | `coruscant` | Coruscant | Imperial base, cityscape | +| 8 | `deathstar` | Death Star | Imperial base, superweapon | +| 9 | `endor` | Endor | Imperial base, forest moon | + +--- + +## 3. Menu icon — `game-icons.png` frame **78** *(shared sheet)* + +| | | +|---|---| +| **File** | `public/assets/images/game-icons.png` (existing shared sheet) | +| **Sheet size** | 660 × 660 px | +| **Frame size** | **44 × 44 px** | +| **Layout** | 15 columns × 15 rows | +| **SWDBG frame** | **78** → row 5, col 3 → pixel box **x 132–176, y 220–264** | + +Add a 44×44 icon for the Star Wars Deckbuilder into cell 78 of the existing +sheet (the menu tile pulls `game-icons` frame `78`). Keep it readable at tiny +size — e.g. a crossed rebel/imperial insignia, a small TIE fighter or X-Wing +silhouette, or the Force-track balance symbol. This is the only sprite that +goes into an **existing** sheet rather than a new file. + +--- + +## Quick checklist + +- [ ] `swdbg-cards-art.png` — 2400×1610 (8×7 grid), 300×230 per frame, 55 frames (0–54); then set `cardSheet.path` in `swdbg-artwork.json`. + - Priority 1 (uniques/leaders): 14–18, 28–35, 40, 49–52, 54 + - Priority 2 (common starters): 0–5, 7–9, 22–24, 26–27 + - Priority 3 (remaining Empire/Rebel commons): 10–13, 19–21, 36–39 + - Priority 4 (neutral/outer rim): 6, 41–48, 53 +- [ ] `swdbg-bases-art.png` — 1100×600 (5×2 grid), 220×300 per frame, 10 frames (0–9); then set `baseSheet.path` in `swdbg-artwork.json`. +- [ ] `game-icons.png` frame 78 — 44×44 menu icon. + +The frame→id mappings for both sheets are already filled in in +`public/data/swdbg-artwork.json` — once you drop the PNGs in and set the two +`path` fields, art appears with no code changes. diff --git a/src/games/swdbg/tutorial.md b/src/games/swdbg/tutorial.md new file mode 100644 index 0000000..018bb93 --- /dev/null +++ b/src/games/swdbg/tutorial.md @@ -0,0 +1,72 @@ +# Star Wars Deckbuilder + +> **A protocol droid shuffles forward and bows stiffly…** +> +> Greetings! I am T-3PO, tactical liaison droid, fluent in over six million +> forms of warfare. You and your opponent will each take a side — the Galactic +> Empire or the Rebel Alliance — and wage war across the galaxy one card at a +> time. The first commander to **destroy three enemy bases** wins. The odds of +> surviving all three? I'd really rather not say. + +## Your Turn, Commander + +Each turn you may do any of the following, in any order, as often as you like: + +1. **Play cards** from your hand. Each card grants its printed **⚔ attack**, + **▣ resources**, and **◈ Force** the moment it hits the table. +2. **Purchase cards** from the six-card **galaxy row** with resources. You may + buy cards of **your faction** and **neutral** cards — never the enemy's. + The **Outer Rim Pilot** stack is always for sale. Purchases go to your + discard pile and join your deck when it reshuffles. +3. **Use abilities** — each card in play may use its ability once per turn. +4. **Attack!** Group your units and strike (see below). + +At the end of your turn, everything you played and everything left in your +hand is discarded, unspent resources evaporate, and you draw five fresh cards. + +## Two Ways to Attack + +- **Strike the enemy base.** Total the ⚔ of your committed cards. The enemy's + **capital ships** must be destroyed before a single point reaches their + base — but damage on ships and bases *sticks* between turns. Grind them down! +- **Bounty hunting / sabotage.** Enemy-faction cards in the galaxy row show a + **◎ target value**. Meet it with committed units and the card is destroyed — + denying it to your rival — and you pocket the printed **reward**. Capital + ships can't join these missions; they have a war to fight. + +Each card may join only one attack per turn. + +## The Balance of the Force + +Cards with **◈** slide the Force marker toward your side of the track. + +- While the marker is on **your side**, "If the Force is with you…" abilities + switch on and several cards grow stronger. +- If it is **all the way** to your side at the start of your turn: **+1 + resource**. The Rebels begin the game with the Force fully behind them — + the Empire begins with the first move. Fair? War rarely is. + +## Bases and Capital Ships + +You defend **five bases**, one at a time, starting with your outpost. When a +base falls, you pick your next stronghold at the start of your following turn — +each has more hit points and a fresh ability. Lose three and the war is over. + +**Capital ships** stay in play turn after turn, producing resources every +round and adding their guns to every assault. They are magnificent — and they +make magnificent targets. + +## T-3PO's Tactical Memoranda + +- **Thin your deck.** Exiling starter shuttles (Jawas and Mystics oblige) + means drawing your good cards twice as often. This is mathematics, not + treason. +- **Deny the row.** If a card would be splendid in your enemy's deck, bounty + hunt it before they can afford it. +- **Mind the Balance.** Ceding the Force to a foe full of "if the Force is + with you" cards is how bases end up as craters. +- **Ships before base.** Attacking into a wall of capital ships wastes + firepower — unless you soften them one turn and finish the job the next. + Damage, delightfully, is permanent. + +Now then — the galaxy won't conquer itself. Do try to keep me in one piece. diff --git a/src/main.js b/src/main.js index f0967d0..989ea05 100644 --- a/src/main.js +++ b/src/main.js @@ -84,6 +84,8 @@ import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js'; import SpireClimbGame from './games/spireclimb/SpireClimbGame.js'; import AzulGame from './games/azul/AzulGame.js'; import JumbleGame from './games/jumble/JumbleGame.js'; +import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js'; +import SWDBGGame from './games/swdbg/SWDBGGame.js'; const config = { type: Phaser.AUTO, @@ -181,6 +183,8 @@ const config = { SpireClimbGame, AzulGame, JumbleGame, + DungeonBossGame, + SWDBGGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 8e439b5..a809393 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -22,7 +22,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' }; + 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' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/src/scenes/OpponentSelectScene.js b/src/scenes/OpponentSelectScene.js index 9883971..c8d2803 100644 --- a/src/scenes/OpponentSelectScene.js +++ b/src/scenes/OpponentSelectScene.js @@ -403,7 +403,7 @@ export default class OpponentSelectScene extends Phaser.Scene { // Skill control: pips always show the level; the +/- buttons appear only // when this opponent is selected. Enabled for games with a 1–5 AI skill. - if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul'].includes(this.gameDef.slug)) { + if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul', 'dungeonboss', 'swdbg'].includes(this.gameDef.slug)) { bio.style.webkitLineClamp = '1'; const skillRow = document.createElement('div'); diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 45745fa..2750f91 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -73,6 +73,9 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('katamino', 'data/katamino.json'); this.load.json('bookwork', 'data/bookwork.json'); this.load.json('spireclimb-artwork', 'data/spireclimb-artwork.json'); + this.load.json('dungeonboss-artwork', 'data/dungeonboss-artwork.json'); + this.load.json('swdbg-cards', 'data/swdbg-cards.json'); + this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png'); this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png'); @@ -225,9 +228,19 @@ export default class PreloadScene extends Phaser.Scene { const scSheets = [scArt?.cardSheet, scArt?.creatureSheet, ...Object.values(scArt?.playerSheets || {})] .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - if (toLoad.length > 0 || scSheets.length > 0) { + // Dungeon Boss drop-in spritesheets, same contract as Spire Climb's. + const dbArt = this.cache.json.get('dungeonboss-artwork'); + const dbSheets = [dbArt?.roomSheet, dbArt?.heroSheet, dbArt?.spellSheet, dbArt?.bossSheet] + .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); + + // Star Wars Deckbuilder drop-in spritesheets, same contract. + const swArt = this.cache.json.get('swdbg-artwork'); + const swSheets = [swArt?.cardSheet, swArt?.baseSheet] + .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); + + if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0) { for (const asset of toLoad) this.load.image(asset.key, asset.path); - for (const s of scSheets) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); + for (const s of [...scSheets, ...dbSheets, ...swSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); await new Promise((resolve) => { this.load.once('complete', resolve); this.load.start(); diff --git a/tools/verifyDungeonBoss.js b/tools/verifyDungeonBoss.js new file mode 100644 index 0000000..6845347 --- /dev/null +++ b/tools/verifyDungeonBoss.js @@ -0,0 +1,322 @@ +// verifyDungeonBoss.js — headless checks for the Dungeon Boss engine. +// Run from repo root: node tools/verifyDungeonBoss.js +// Imports only Data/Logic/AI (never the Phaser scene). + +import { + CLASSES, BOSSES, ROOMS, SPELLS, HEROES, OPS, PASSIVES, + HERO_DECK_SIZING, heroSouls, heroWounds, roomDef, spellDef, heroDef, +} from '../src/games/dungeonboss/DungeonBossData.js'; +import { + newGame, makeRng, pendingDecision, takeEvents, publicView, + actSetupDiscard, actBuild, actWindow, actReact, actChooseTarget, actDiscard, actRoomDraw, + legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets, + isOver, finalRanking, SOULS_TO_WIN, WOUNDS_TO_DIE, MAX_ROOMS, +} from '../src/games/dungeonboss/DungeonBossLogic.js'; +import { decide } from '../src/games/dungeonboss/DungeonBossAI.js'; + +let pass = 0; +let fail = 0; +function ok(cond, msg) { + if (cond) { pass++; return true; } + fail++; + console.error(` ✗ ${msg}`); + return false; +} + +// ── 1. Content sanity ──────────────────────────────────────────────────────── +console.log('Content sanity…'); +{ + ok(Object.keys(BOSSES).length === 8, 'exactly 8 bosses'); + const xps = Object.values(BOSSES).map((b) => b.xp); + ok(new Set(xps).size === 8, 'boss XP values unique'); + for (const [id, b] of Object.entries(BOSSES)) { + ok(CLASSES.includes(b.treasure), `boss ${id} treasure class valid`); + ok(OPS.includes(b.levelUp.op), `boss ${id} level-up op supported`); + ok(!!b.name && !!b.text, `boss ${id} name/text present`); + } + + let roomCards = 0; + for (const [id, r] of Object.entries(ROOMS)) { + roomCards += r.count; + ok(['monster', 'trap'].includes(r.type), `room ${id} type valid`); + ok(r.dmg >= 0, `room ${id} damage >= 0`); + ok(Object.keys(r.treasure || {}).every((c) => CLASSES.includes(c)), `room ${id} treasure classes valid`); + for (const eff of (r.effects || [])) ok(OPS.includes(eff.op), `room ${id} op ${eff.op} supported`); + if (r.passive) ok(PASSIVES.includes(r.passive), `room ${id} passive ${r.passive} supported`); + if (r.advanced) { + ok(Object.keys(r.treasure).length > 0, `advanced room ${id} has a treasure icon (buildable)`); + } + ok(!!r.name && !!r.text, `room ${id} name/text present`); + } + ok(roomCards === 75, `room deck is 75 cards (got ${roomCards})`); + + let spellCards = 0; + for (const [id, s] of Object.entries(SPELLS)) { + spellCards += s.count; + ok(['build', 'adventure', 'both'].includes(s.phase), `spell ${id} phase valid`); + ok(OPS.includes(s.op.op), `spell ${id} op supported`); + ok(!!s.name && !!s.text, `spell ${id} name/text present`); + } + ok(spellCards === 31, `spell deck is 31 cards (got ${spellCards})`); + + const ordinary = Object.values(HEROES).filter((h) => !h.epic); + const epics = Object.values(HEROES).filter((h) => h.epic); + ok(ordinary.length === 25, `25 ordinary heroes (got ${ordinary.length})`); + ok(epics.length === 16, `16 epic heroes (got ${epics.length})`); + for (const cls of CLASSES) { + const o = ordinary.filter((h) => h.cls === cls); + ok(o.length === 6, `6 ordinary ${cls} heroes`); + ok(o.some((h) => h.hp <= 4), `${cls} has a killable low-hp hero`); + ok(epics.filter((h) => h.cls === cls).length === 4, `4 epic ${cls} heroes`); + } + ok(Object.values(HEROES).filter((h) => h.fool).length === 1, 'exactly one Fool'); +} + +// ── 2. Setup invariants ────────────────────────────────────────────────────── +console.log('Setup invariants…'); +{ + for (const n of [2, 3, 4]) { + const st = newGame(n, 12345 + n); + ok(st.players.length === n, `${n}p: player count`); + for (const p of st.players) { + ok(p.hand.rooms.length === 5 && p.hand.spells.length === 2, `${n}p: dealt 5 rooms + 2 spells`); + } + const sizing = HERO_DECK_SIZING[n]; + ok(st.decks.heroes.length === sizing.ordinaryPerClass * 4 + 1, + `${n}p: ordinary hero deck sized (${st.decks.heroes.length})`); + ok(st.decks.epics.length === sizing.epicPerClass * 4, `${n}p: epic deck sized`); + const d = pendingDecision(st); + ok(d && d.kind === 'setupDiscard', `${n}p: first decision is setupDiscard`); + // Turn order sorted by XP descending. + const xps = st.turnOrder.map((s) => BOSSES[st.players[s].boss.id].xp); + ok(xps.every((x, i) => i === 0 || xps[i - 1] >= x), `${n}p: turn order by XP desc`); + } +} + +// ── AI driver ──────────────────────────────────────────────────────────────── +function driveDecision(st, d, skill, rnd, tally) { + const view = publicView(st, d.seat); + const choice = decide(view, d, skill, rnd); + switch (d.kind) { + case 'setupDiscard': actSetupDiscard(st, d.seat, choice); break; + case 'build': { + if (choice) { + const legal = legalBuilds(st, d.seat); + ok(legal.some((b) => b.roomUid === choice.roomUid && b.slotIdx === choice.slotIdx), + 'AI build choice is legal'); + } + actBuild(st, d.seat, choice); + break; + } + case 'window': actWindow(st, d.seat, choice); if (tally && choice && !choice.pass) tally.casts++; break; + case 'react': actReact(st, d.seat, choice); break; + case 'target': actChooseTarget(st, d.seat, choice); break; + case 'discard': actDiscard(st, d.seat, choice); break; + case 'roomDraw': actRoomDraw(st, d.seat, choice); break; + default: throw new Error(`unknown decision kind ${d.kind}`); + } +} + +function playGame(nPlayers, seed, skills, collect = null) { + const st = newGame(nPlayers, seed); + const rnd = makeRng(seed ^ 0x5eed); + const tally = { casts: 0 }; + let steps = 0; + const eventLog = []; + while (!isOver(st)) { + if (++steps > 20000) throw new Error(`game ${seed} exceeded step guard (round ${st.round})`); + const d = pendingDecision(st); + if (!d) throw new Error(`game ${seed}: no decision but game not over (phase ${st.phase}, round ${st.round})`); + if (!st.players[d.seat].alive) throw new Error(`game ${seed}: decision for dead seat ${d.seat}`); + driveDecision(st, d, skills[d.seat] ?? 3, rnd, tally); + checkInvariants(st, seed); + if (collect !== null) eventLog.push(...takeEvents(st).map((e) => JSON.stringify(e))); + else takeEvents(st); + } + if (collect !== null) collect.push(...eventLog); + return { st, tally, steps }; +} + +function checkInvariants(st, seed) { + for (const p of st.players) { + if (p.dungeon.length > MAX_ROOMS) throw new Error(`game ${seed}: dungeon > ${MAX_ROOMS}`); + if (p.alive && p.wounds >= WOUNDS_TO_DIE) throw new Error(`game ${seed}: alive at ${p.wounds} wounds`); + if (!p.alive && p.dungeon.length) throw new Error(`game ${seed}: dead seat kept a dungeon`); + const soulSum = p.soulCards.reduce((a, h) => a + heroSouls(heroDef(h)), 0); + if (soulSum !== p.souls) throw new Error(`game ${seed}: souls ${p.souls} != soul cards ${soulSum}`); + } +} + +// ── 3. Determinism ─────────────────────────────────────────────────────────── +console.log('Determinism…'); +{ + const logA = []; + const logB = []; + playGame(4, 777, [3, 3, 3, 3], logA); + playGame(4, 777, [3, 3, 3, 3], logB); + ok(logA.length === logB.length && logA.every((e, i) => e === logB[i]), + `same seed reproduces the same event log (${logA.length} events)`); +} + +// ── 4. Self-play soak ──────────────────────────────────────────────────────── +console.log('Self-play soak (320 games)…'); +{ + let totalRounds = 0; + let totalCasts = 0; + let eliminations = 0; + const winReasons = {}; + const winsBySeat = {}; + let games = 0; + for (let g = 0; g < 320; g++) { + const nPlayers = 2 + (g % 3); + const skills = Array.from({ length: nPlayers }, (_, i) => 1 + ((g + i) % 5)); + const { st, tally } = playGame(nPlayers, 1000 + g * 7, skills); + games++; + totalRounds += st.round; + totalCasts += tally.casts; + eliminations += st.players.filter((p) => !p.alive).length; + const over = st.events; // drained already; use final state + ok(st.winner != null, `game ${g}: has a winner`); + const w = st.players[st.winner]; + const reason = w.souls >= SOULS_TO_WIN ? 'souls' + : st.players.filter((p) => p.alive).length === 1 ? 'lastStanding' : 'decksExhausted'; + winReasons[reason] = (winReasons[reason] || 0) + 1; + winsBySeat[st.winner] = (winsBySeat[st.winner] || 0) + 1; + ok(w.alive, `game ${g}: winner is alive`); + ok(w.souls >= SOULS_TO_WIN || st.players.filter((p) => p.alive).length === 1 + || (st.decks.heroes.length === 0 && st.decks.epics.length === 0), + `game ${g}: valid end condition`); + // Epic activation implies the ordinary deck is empty. + if (st.epicsActive) ok(st.decks.heroes.length === 0, `game ${g}: epics only after ordinary deck empty`); + const ranking = finalRanking(st); + ok(ranking[0] === st.winner || !w.alive === false, `game ${g}: winner ranks first`); + } + console.log(` ${games} games · avg rounds ${(totalRounds / games).toFixed(1)}` + + ` · spell/ability activations ${totalCasts}` + + ` · eliminations ${eliminations}` + + ` · endings ${JSON.stringify(winReasons)}` + + ` · wins by seat ${JSON.stringify(winsBySeat)}`); + ok(totalCasts > games, 'AI actually casts spells / activates rooms'); + ok((winReasons.souls || 0) > games * 0.25, 'a healthy share of mixed-skill games end on 10 souls'); +} + +// Skilled play should mostly end on souls, weak play on elimination. +console.log('Skill gradient (60 games @ skill 5)…'); +{ + let souls = 0; + for (let g = 0; g < 60; g++) { + const nPlayers = 2 + (g % 3); + const { st } = playGame(nPlayers, 5000 + g, Array(nPlayers).fill(5)); + if (st.players[st.winner].souls >= SOULS_TO_WIN) souls++; + } + ok(souls >= 40, `skill-5 games end on souls (${souls}/60)`); +} + +// ── 5. Scripted rule spot-checks ───────────────────────────────────────────── +console.log('Rule spot-checks…'); + +// Drive a fresh game up to the first build phase with scripted choices. +function driveTo(st, predicate, maxSteps = 5000) { + const rnd = makeRng(99); + let steps = 0; + while (!isOver(st)) { + const d = pendingDecision(st); + if (!d) throw new Error('stalled'); + if (predicate(d, st)) return d; + driveDecision(st, d, 3, rnd, null); + takeEvents(st); + if (++steps > maxSteps) throw new Error('driveTo exceeded step guard'); + } + return null; +} + +{ + // Bait: strict max wins the hero, ties stay in town. + const st = newGame(2, 4242); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const bt = baitTargets(st); + for (const hero of st.town) { + const def = heroDef(hero); + if (def.fool) { + const [a, b] = [st.players[0].souls, st.players[1].souls]; + ok((a === b) === (bt[hero.uid] == null), 'fool bait: tie stays, otherwise fewest souls'); + } else { + const counts = st.players.map((p) => treasureCount(st, p.seat, def.cls)); + const max = Math.max(...counts); + const winners = counts.filter((c) => c === max && c > 0).length; + if (winners === 1 && max > 0) ok(counts[bt[hero.uid]] === max, `bait goes to strict max for ${def.cls}`); + else ok(bt[hero.uid] == null, 'bait tie (or all zero) stays in town'); + } + } +} + +{ + // Advanced rooms only over a shared treasure icon; ordinary anywhere. + const st = newGame(2, 555); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const seat = pendingDecision(st).seat; + const p = st.players[seat]; + // Inject a known hand/dungeon to make the check exact. + const mk = (id) => ({ uid: 900000 + Math.floor(Math.random() * 100000), id }); + p.hand.rooms = [mk('beastmenagerie'), mk('goblinarmory')]; // advanced fighter / ordinary + p.dungeon = [ + { room: mk('brainsuckerhive'), under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }, // mage + { room: mk('golemfactory'), under: [], deactivated: false, usedOnce: {}, tempDmg: 0, armed: null }, // fighter + ]; + const legal = legalBuilds(st, seat); + const adv = p.hand.rooms[0]; + const advSlots = legal.filter((b) => b.roomUid === adv.uid).map((b) => b.slotIdx); + ok(!advSlots.includes(0), 'advanced room cannot go over a non-matching room'); + ok(advSlots.includes(1), 'advanced room can go over a matching (fighter) room'); + ok(!advSlots.includes(2), 'advanced room cannot open a new slot'); + const ordSlots = legal.filter((b) => b.roomUid === p.hand.rooms[1].uid).map((b) => b.slotIdx); + ok(ordSlots.includes(0) && ordSlots.includes(1) && ordSlots.includes(2), 'ordinary room can build anywhere'); +} + +{ + // Level-up fires exactly once, on the 5th room. + let leveledEvents = 0; + const st = newGame(2, 31337); + const rnd = makeRng(1); + let steps = 0; + while (!isOver(st) && steps < 20000) { + steps++; + const d = pendingDecision(st); + if (!d) break; + driveDecision(st, d, 5, rnd, null); + for (const e of takeEvents(st)) { + if (e.type === 'levelUp') { + leveledEvents++; + ok(st.players[e.seat].dungeon.length === MAX_ROOMS, 'level-up at exactly 5 rooms'); + ok(st.players[e.seat].boss.leveledUp, 'level-up flag set'); + } + } + } + ok(leveledEvents <= st.players.length, 'level-up at most once per player'); +} + +{ + // Surviving heroes wound the boss; epic heroes wound twice. + const st = newGame(2, 90210); + driveTo(st, (d) => d.kind === 'build' && !d.setup); + const seat = st.turnOrder[0]; + const p = st.players[seat]; + p.dungeon = []; // empty dungeon: any hero walks straight through + st.town = []; // keep the bait phase from adding extra heroes to the walk + const tough = Object.keys(HEROES).find((id) => HEROES[id].epic); + p.entrance = [{ uid: 777777, id: tough, hp: HEROES[tough].hp, hpMax: HEROES[tough].hp }]; + const before = p.wounds; + // Run just the adventure by driving until the entrance empties. + const rnd = makeRng(5); + let steps = 0; + while (p.entrance.length && !isOver(st) && steps < 5000) { + steps++; + const d = pendingDecision(st); + driveDecision(st, d, 1, rnd, null); + takeEvents(st); + } + ok(p.wounds - before === 2 || !p.alive, `epic hero deals 2 wounds (got ${p.wounds - before})`); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tools/verifySWDBG.js b/tools/verifySWDBG.js new file mode 100644 index 0000000..2f98692 --- /dev/null +++ b/tools/verifySWDBG.js @@ -0,0 +1,380 @@ +// verifySWDBG.js — headless checks for the Star Wars Deckbuilder engine. +// Run from repo root: node tools/verifySWDBG.js +// Imports only Data/Logic/AI (never the Phaser scene). Card content is read +// from data/swdbg-cards.json, the same file the game loads at runtime. + +import { readFileSync } from 'node:fs'; +import { + loadCardData, getData, cardDef, baseDef, basesFor, + OPS, TIMINGS, CONSTANT_OPS, FACTIONS, +} from '../src/games/swdbg/SWDBGData.js'; +import { + newGame, makeRng, pendingDecision, takeEvents, publicView, legalActions, + actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, + entryAttack, forceWith, forcePegged, purchaseCost, isOver, FORCE_MAX, +} from '../src/games/swdbg/SWDBGLogic.js'; +import { decide } from '../src/games/swdbg/SWDBGAI.js'; + +const json = JSON.parse(readFileSync(new URL('../data/swdbg-cards.json', import.meta.url), 'utf8')); +loadCardData(json); + +let pass = 0; +let fail = 0; +function ok(cond, msg) { + if (cond) { pass++; return true; } + fail++; + console.error(` ✗ ${msg}`); + return false; +} + +// ── 1. Content sanity ──────────────────────────────────────────────────────── +console.log('Content sanity…'); +{ + const counts = { empire: 0, rebel: 0, neutral: 0 }; + let total = 0; + const checkAbility = (id, ab, allowed = TIMINGS) => { + if (!ab) return; + ok(allowed.includes(ab.timing || 'action') || ab.timing === undefined, `${id}: ability timing valid`); + const ops = ab.op === 'choose' ? ab.options.map((o) => o.op) : [ab.op]; + for (const op of ops) ok(OPS.includes(op), `${id}: op ${op} supported`); + if (ab.timing === 'constant') ok(CONSTANT_OPS.includes(ab.op), `${id}: constant op is passive`); + if (ab.timing === 'action' || !ab.timing) ok(!CONSTANT_OPS.includes(ab.op), `${id}: action op is active`); + }; + for (const def of json.cards) { + total += def.count; + counts[def.faction] += def.count; + ok(FACTIONS.includes(def.faction) || def.faction === 'neutral', `${def.id}: faction valid`); + ok(['unit', 'capital'].includes(def.type), `${def.id}: type valid`); + ok(def.cost >= 0 && def.count >= 1, `${def.id}: cost/count sane`); + ok(!!def.name && !!def.text, `${def.id}: name/text present`); + if (def.type === 'capital') { + ok(def.hp > 0, `${def.id}: capital has hp`); + ok(def.target == null, `${def.id}: capital ships cannot be bounty targets`); + } + if (def.faction === 'neutral') { + ok(def.target == null && def.reward == null, `${def.id}: neutral has no target/reward`); + } else if (def.type === 'unit') { + ok(def.target != null && def.target > 0, `${def.id}: faction unit has a target value`); + ok(!!def.reward, `${def.id}: faction unit has a reward`); + } + if (def.unique) ok(def.count === 1, `${def.id}: unique is a 1-of`); + checkAbility(def.id, def.ability); + } + ok(total === 90, `galaxy deck is 90 cards (got ${total})`); + ok(counts.empire === 30, `30 Empire cards (got ${counts.empire})`); + ok(counts.rebel === 30, `30 Rebel cards (got ${counts.rebel})`); + ok(counts.neutral === 30, `30 neutral cards (got ${counts.neutral})`); + + for (const f of FACTIONS) { + const starters = json.starters[f].reduce((a, s) => a + s.count, 0); + ok(starters === 10, `${f} starter deck is 10 cards`); + for (const s of json.starters[f]) ok(!!cardDef(s.id), `${f} starter ${s.id} defined`); + const bases = basesFor(f); + ok(bases.length >= 5, `${f} has at least 5 bases`); + ok(bases.filter((b) => b.starting).length === 1, `${f} has exactly one starting base`); + for (const b of bases) { + ok(b.hp > 0 && !!b.name && !!b.text, `${f} base ${b.id} fields`); + checkAbility(`base ${b.id}`, b.ability, ['action', 'constant', 'onReveal']); + } + } + ok(json.outerRimPilot.count === 10, 'Outer Rim Pilot stack is 10'); + checkAbility('outerrimpilot', json.outerRimPilot.ability); + ok(json.meta.basesToWin === 3 && json.meta.handSize === 5 && json.meta.galaxyRowSize === 6, 'meta matches the beginner rules'); +} + +// ── 2. Setup invariants ────────────────────────────────────────────────────── +console.log('Setup invariants…'); +{ + for (const faction of FACTIONS) { + const st = newGame({ humanFaction: faction, seed: 424242 }); + ok(st.players[0].faction === faction, `human plays ${faction}`); + ok(st.players[1].faction !== faction, 'AI plays the other side'); + ok(st.players.every((p) => p.hand.length === 5), 'both draw 5'); + ok(st.players.every((p) => p.deck.length === 5), '5 left in each deck'); + ok(st.galaxy.row.length === 6, 'galaxy row is 6'); + ok(st.galaxy.deck.length === 84, '84 cards left in the galaxy deck'); + ok(st.outerRim.length === 10, '10 Outer Rim Pilots'); + ok(st.force === FORCE_MAX, 'Force starts pegged to the Rebel side'); + ok(st.players[st.turnSeat].faction === 'empire', 'Empire takes the first turn'); + ok(st.players.every((p) => baseDef(p.faction, p.base.id).starting), 'both start on their starting base'); + const d = pendingDecision(st); + ok(d && d.kind === 'turn' && d.seat === st.turnSeat, 'first decision is the Empire turn'); + const rebelSeat = st.players.findIndex((p) => p.faction === 'rebel'); + ok(forceWith(st, rebelSeat) && forcePegged(st, rebelSeat), 'the Force is with the Rebels at setup'); + ok(!forceWith(st, 1 - rebelSeat), 'and not with the Empire'); + } +} + +// ── AI driver ──────────────────────────────────────────────────────────────── +function driveDecision(st, d, skill, rnd, tally = null) { + const view = publicView(st, d.seat); + const choice = decide(view, d, skill, rnd); + switch (d.kind) { + case 'turn': + if (tally) tally[choice.type] = (tally[choice.type] || 0) + 1; + actTurn(st, d.seat, choice); + break; + case 'chooseBase': actChooseBase(st, d.seat, choice); break; + case 'target': actChooseTarget(st, d.seat, choice); break; + case 'oppDiscard': actOppDiscard(st, d.seat, choice); break; + case 'spyChoice': actSpyChoice(st, d.seat, choice); break; + case 'chooseOption': actChooseOption(st, d.seat, choice); break; + default: throw new Error(`unknown decision kind ${d.kind}`); + } +} + +function countAll(st) { + // Every card instance must live in exactly one zone. + let n = st.galaxy.deck.length + st.galaxy.row.length + st.galaxy.discard.length + + st.outerRim.length + st.exile.length; + for (const p of st.players) { + n += p.deck.length + p.hand.length + p.discard.length + p.inPlay.length + p.capitals.length; + } + return n; +} + +function checkInvariants(st, seed) { + if (st.force < -FORCE_MAX || st.force > FORCE_MAX) throw new Error(`game ${seed}: force off track (${st.force})`); + for (const p of st.players) { + if (p.resources < 0) throw new Error(`game ${seed}: negative resources`); + if (p.base) { + const def = baseDef(p.faction, p.base.id); + if (p.base.damage >= def.hp) throw new Error(`game ${seed}: destroyed base still in play`); + } + if (!st.over && p.lostBases >= st.meta.basesToWin) throw new Error(`game ${seed}: lost ${p.lostBases} bases but game continues`); + for (const e of p.capitals) { + if (e.damage >= cardDef(e.card).hp) throw new Error(`game ${seed}: destroyed capital still in play`); + } + } +} + +function playGame(seed, skills, collect = null) { + const st = newGame({ humanFaction: seed % 2 ? 'rebel' : 'empire', seed }); + const rnd = makeRng(seed ^ 0x5eed); + const expectTotal = countAll(st); + const tally = {}; + let steps = 0; + while (!isOver(st)) { + if (++steps > 40000) throw new Error(`game ${seed} exceeded step guard (turn ${st.turnCount})`); + const d = pendingDecision(st); + if (!d) throw new Error(`game ${seed}: no decision but game not over (turn ${st.turnCount})`); + driveDecision(st, d, skills[d.seat] ?? 3, rnd, tally); + checkInvariants(st, seed); + if (countAll(st) !== expectTotal) throw new Error(`game ${seed}: card count drifted (${countAll(st)} != ${expectTotal})`); + if (collect !== null) collect.push(...takeEvents(st).map((e) => JSON.stringify(e))); + else takeEvents(st); + } + return { st, tally, steps }; +} + +// ── 3. Determinism ─────────────────────────────────────────────────────────── +console.log('Determinism…'); +{ + const logA = []; + const logB = []; + playGame(777, [3, 3], logA); + playGame(777, [3, 3], logB); + ok(logA.length === logB.length && logA.every((e, i) => e === logB[i]), + `same seed reproduces the same event log (${logA.length} events)`); +} + +// ── 4. Self-play soak ──────────────────────────────────────────────────────── +console.log('Self-play soak (300 games)…'); +{ + let games = 0; + let totalTurns = 0; + const tally = {}; + const winsByFaction = { empire: 0, rebel: 0 }; + let maxTurns = 0; + for (let g = 0; g < 300; g++) { + const skills = [1 + (g % 5), 1 + ((g + 2) % 5)]; + const { st, tally: t } = playGame(1000 + g * 7, skills); + games++; + totalTurns += st.turnCount; + maxTurns = Math.max(maxTurns, st.turnCount); + for (const [k, v] of Object.entries(t)) tally[k] = (tally[k] || 0) + v; + ok(st.winner != null, `game ${g}: has a winner`); + const w = st.players[st.winner]; + const l = st.players[1 - st.winner]; + ok(l.lostBases >= st.meta.basesToWin, `game ${g}: loser lost ${st.meta.basesToWin}+ bases`); + ok(w.lostBases < st.meta.basesToWin, `game ${g}: winner kept a base standing`); + winsByFaction[w.faction]++; + } + console.log(` ${games} games · avg turns ${(totalTurns / games).toFixed(1)} (max ${maxTurns})` + + ` · wins ${JSON.stringify(winsByFaction)}` + + ` · actions ${JSON.stringify(tally)}`); + ok(tally.buy > games * 3, 'AI buys cards'); + ok((tally.attackRow || 0) > games * 0.3, 'AI bounty hunts / sabotages'); + ok((tally.ability || 0) > games, 'AI uses abilities'); + ok(totalTurns / games < 80, 'games end in a reasonable number of turns'); + const skew = Math.abs(winsByFaction.empire - winsByFaction.rebel) / games; + ok(skew < 0.35, `faction win rates not wildly skewed (skew ${(skew * 100).toFixed(0)}%)`); +} + +// Skill gradient: a 5 should beat a 1 far more often than not. +console.log('Skill gradient (80 games, 5 vs 1)…'); +{ + let strongWins = 0; + for (let g = 0; g < 80; g++) { + const strongSeat = g % 2; + const skills = strongSeat === 0 ? [5, 1] : [1, 5]; + const { st } = playGame(9000 + g * 13, skills); + if (st.winner === strongSeat) strongWins++; + } + console.log(` skill-5 wins ${strongWins}/80`); + ok(strongWins >= 52, `skill 5 dominates skill 1 (${strongWins}/80)`); +} + +// ── 5. Scripted rule spot-checks ───────────────────────────────────────────── +console.log('Rule spot-checks…'); + +function freshGame(seed = 31337) { + return newGame({ humanFaction: 'rebel', seed }); +} +function seatOf(st, faction) { return st.players.findIndex((p) => p.faction === faction); } +function give(st, seat, id) { + // Inject a known card into a player's play area for scripted checks. + const inst = { uid: 900000 + Math.floor(Math.random() * 99999), id }; + const entry = { card: inst, tempAttack: 0, used: false, committed: false }; + if (cardDef(id).type === 'capital') { entry.damage = 0; st.players[seat].capitals.push(entry); } + else st.players[seat].inPlay.push(entry); + return entry; +} + +{ + // Faction purchase restriction: Empire cannot buy Rebel cards, anyone buys neutral. + const st = freshGame(101); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + st.players[emp].resources = 20; + const rebelRow = st.galaxy.row.find((c) => cardDef(c).faction === 'rebel'); + if (rebelRow) { + let threw = false; + try { actTurn(st, emp, { type: 'buy', uid: rebelRow.uid }); } catch { threw = true; } + ok(threw, 'Empire cannot purchase a Rebel card'); + } + const legal = legalActions(st, emp); + ok(legal.buys.every((b) => { + if (b.uid === 'outerrim') return true; + const f = cardDef(st.galaxy.row.find((c) => c.uid === b.uid)).faction; + return f === 'empire' || f === 'neutral'; + }), 'legal buys are own-faction or neutral only'); + ok(legal.buys.some((b) => b.uid === 'outerrim'), 'Outer Rim Pilot is always purchasable'); +} + +{ + // Capital ships absorb an attack before the base; damage persists. + const st = freshGame(202); + const emp = seatOf(st, 'empire'); + const reb = 1 - emp; + st.turnSeat = reb; + st.players[reb].resources = 0; + const cap = give(st, emp, 'gozanticruiser'); // hp 3 + const atk = give(st, reb, 'quarrenmercenary'); // 4 attack + const baseBefore = st.players[emp].base.damage; + actTurn(st, reb, { type: 'attackBase', uids: [atk.card.uid] }); + ok(st.players[emp].capitals.length === 0, 'capital destroyed by the attack'); + ok(st.players[emp].discard.some((c) => c.uid === cap.card.uid), 'destroyed capital goes to owner discard'); + ok(st.players[emp].base.damage === baseBefore + 1, 'excess damage rolls onto the base'); + // A second attacker cannot re-commit the same unit. + let threw = false; + try { actTurn(st, reb, { type: 'attackBase', uids: [atk.card.uid] }); } catch { threw = true; } + ok(threw, 'a committed unit cannot attack twice'); +} + +{ + // Direct ability damage ignores capital ships; base destruction wins at 3. + const st = freshGame(303); + const emp = seatOf(st, 'empire'); + const reb = 1 - emp; + st.turnSeat = emp; + give(st, reb, 'moncalamaricruiser'); // big capital that would otherwise absorb + const bomber = give(st, emp, 'tiebomber'); + st.force = -FORCE_MAX; // Force with the Empire → bomber deals 2 + const dmgBefore = st.players[reb].base.damage; + actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber.card.uid }); + ok(st.players[reb].base.damage === dmgBefore + 2, 'ability damage hits the base directly (Force-boosted)'); + ok(st.players[reb].capitals.length === 1, 'capital untouched by direct damage'); + + st.players[reb].base.damage = baseDef('rebel', st.players[reb].base.id).hp - 1; + st.players[reb].lostBases = 2; + const bomber2 = give(st, emp, 'tiebomber'); + actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber2.card.uid }); + ok(st.over && st.winner === emp, 'third base destroyed ends the game immediately'); +} + +{ + // Bounty hunting: target value gate, rewards, and galaxy discard. + const st = freshGame(404); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + // Put a known Empire unit in the row. + const target = { uid: 888001, id: 'deathtrooper' }; // target 4, reward 2 resources + st.galaxy.row[0] = target; + const a1 = give(st, reb, 'rebelcommando'); // 3 attack + let threw = false; + try { actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid] }); } catch { threw = true; } + ok(threw, 'attack below target value is rejected'); + const a2 = give(st, reb, 'xwing'); // 3 attack + const resBefore = st.players[reb].resources; + actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid, a2.card.uid] }); + ok(st.galaxy.discard.some((c) => c.uid === target.uid), 'defeated card goes to the galaxy discard'); + ok(st.players[reb].resources === resBefore + 2, 'bounty reward granted'); + ok(st.galaxy.row.length === 6, 'row refilled after the bounty'); + ok(a1.committed && a2.committed, 'bounty attackers are committed'); +} + +{ + // Constant boosts: Imperial Carrier aura + TIE swarm + Rodian row bonus. + const st = freshGame(505); + const emp = seatOf(st, 'empire'); + give(st, emp, 'imperialcarrier'); + const tie1 = give(st, emp, 'tiefighter'); + const tie2 = give(st, emp, 'tiefighter'); + // TIE: 1 base + 1 swarm (another Fighter) + 1 carrier aura = 3 + ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with swarm+carrier is 3 (got ${entryAttack(st, emp, tie1)})`); + const rodian = give(st, emp, 'rodiangunslinger'); + ok(entryAttack(st, emp, rodian) === 2, 'Rodian is 2 attack vs bases'); + ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row'); +} + +{ + // Force mechanics: pegged bonus resource, spy choice restrictions. + const st = freshGame(606); + const emp = seatOf(st, 'empire'); + st.force = -FORCE_MAX; + st.turnSeat = 1 - emp; // let empire's beginTurn run via endTurn + actTurn(st, 1 - emp, { type: 'endTurn' }); + ok(st.players[emp].resources >= 1, 'pegged Force grants a start-of-turn resource'); + + // Duros Spy with victim hand empty auto-resolves to Force gain. + const st2 = freshGame(607); + const reb2 = seatOf(st2, 'rebel'); + st2.turnSeat = reb2; + st2.force = 0; + st2.players[1 - reb2].hand = []; + const spy = give(st2, reb2, 'durosspy'); + actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: spy.card.uid }); + ok(st2.force === 1, 'Duros Spy auto-resolves to Force when victim hand is empty'); +} + +{ + // End of turn: units + hand discarded, capitals persist, hand refills to 5. + const st = freshGame(707); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + const p = st.players[emp]; + give(st, emp, 'stardestroyer'); + actTurn(st, emp, { type: 'playAll' }); + const played = p.inPlay.length; + ok(played > 0, 'units in play before end of turn'); + actTurn(st, emp, { type: 'endTurn' }); + ok(p.inPlay.length === 0, 'units discarded at end of turn'); + ok(p.capitals.length === 1, 'capital ship stays in play'); + ok(p.hand.length === 5, 'hand refilled to 5'); + ok(p.resources === 0, 'unspent resources returned'); + ok(st.turnSeat === 1 - emp, 'turn passes'); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);