feat: add Dungeon Boss and Star Wars Deckbuilder card games

Implement two complete card games with full rule engines, AI opponents
(5 skill levels), procedural rendering, and drop-in art support.

Dungeon Boss: Boss Monster-style dungeon building game for 2-4 players.
Players build dungeons of rooms and spells to lure and defeat heroes,
harvesting souls to win. Includes 8 bosses, 31 room cards, 16 spells,
and 41 heroes with class-based baiting mechanics.

Star Wars Deckbuilder: 2-player deckbuilding game. Players build decks
from faction and neutral cards, purchase from the galaxy row, bounty hunt
enemy units, and attack bases. First to destroy 3 enemy bases wins.
Includes 90-card galaxy deck, 10 bases with abilities, and the Force
mechanic.

Both games include:
- Pure logic engines (rules-verified by headless test suites)
- Synchronous heuristic AI with skill-based decision quality
- Phaser scenes with vector-rendered procedural art
- Drop-in sprite sheet support via JSON mappings
- Tutorial documentation and art specifications
- Registration in the game registry and scene loader
This commit is contained in:
Brian Fertig 2026-07-03 07:32:41 -06:00
parent 9b45f7ba6f
commit ab6b2ce9fc
24 changed files with 7862 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

View File

@ -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
}
}

42
data/swdbg-artwork.json Normal file
View File

@ -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
}
}

295
data/swdbg-cards.json Normal file
View File

@ -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." }
]
}
}

View File

@ -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: '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: '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: '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 });

View File

@ -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 15 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;
}
}

View File

@ -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 opponents 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 rivals 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 rivals 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 rivals 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 rivals 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 rivals 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]; }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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 017 (Cleric/Fighter/Mage wings) show up most often; the
> multi-class rooms (2430) 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 (023) plus the Fool (24) are seen every
> game and every player count — paint those first. The 16 epics (2540) 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 88132, y 220264** |
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 (030); then set `roomSheet.path` in `dungeonboss-artwork.json`.
- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (015); then set `spellSheet.path` in `dungeonboss-artwork.json`.
- [ ] `dungeonboss-heroes.png` — 1540×1800 (7×6 grid), 220×300 per frame, 41 frames (040); then set `heroSheet.path` in `dungeonboss-artwork.json`.
- Priority 1: ordinary heroes + Fool (024)
- Priority 2: epic heroes (2540)
- [ ] `dungeonboss-bosses.png` — 1040×720 (4×2 grid), 260×360 per frame, 8 frames (07); 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.

View File

@ -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.

290
src/games/swdbg/SWDBGAI.js Normal file
View File

@ -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 15 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;
}
}

View File

@ -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));
}

1333
src/games/swdbg/SWDBGGame.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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,
};
}

171
src/games/swdbg/sprites.md Normal file
View File

@ -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 (1418, 2835, 40, 4952, 54) and starters/commons that
> show up every game (05, 79, 2224, 2627) 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 132176, y 220264** |
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 (054); then set `cardSheet.path` in `swdbg-artwork.json`.
- Priority 1 (uniques/leaders): 1418, 2835, 40, 4952, 54
- Priority 2 (common starters): 05, 79, 2224, 2627
- Priority 3 (remaining Empire/Rebel commons): 1013, 1921, 3639
- Priority 4 (neutral/outer rim): 6, 4148, 53
- [ ] `swdbg-bases-art.png` — 1100×600 (5×2 grid), 220×300 per frame, 10 frames (09); 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.

View File

@ -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.

View File

@ -84,6 +84,8 @@ import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
import SpireClimbGame from './games/spireclimb/SpireClimbGame.js'; import SpireClimbGame from './games/spireclimb/SpireClimbGame.js';
import AzulGame from './games/azul/AzulGame.js'; import AzulGame from './games/azul/AzulGame.js';
import JumbleGame from './games/jumble/JumbleGame.js'; import JumbleGame from './games/jumble/JumbleGame.js';
import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js';
import SWDBGGame from './games/swdbg/SWDBGGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -181,6 +183,8 @@ const config = {
SpireClimbGame, SpireClimbGame,
AzulGame, AzulGame,
JumbleGame, JumbleGame,
DungeonBossGame,
SWDBGGame,
], ],
}; };

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -403,7 +403,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only // Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill. // when this opponent is selected. Enabled for games with a 15 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'; bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div'); const skillRow = document.createElement('div');

View File

@ -73,6 +73,9 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('katamino', 'data/katamino.json'); this.load.json('katamino', 'data/katamino.json');
this.load.json('bookwork', 'data/bookwork.json'); this.load.json('bookwork', 'data/bookwork.json');
this.load.json('spireclimb-artwork', 'data/spireclimb-artwork.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.json('jumble', 'data/jumble.json');
this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png'); this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png');
this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.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 || {})] const scSheets = [scArt?.cardSheet, scArt?.creatureSheet, ...Object.values(scArt?.playerSheets || {})]
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); .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 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) => { await new Promise((resolve) => {
this.load.once('complete', resolve); this.load.once('complete', resolve);
this.load.start(); this.load.start();

322
tools/verifyDungeonBoss.js Normal file
View File

@ -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);

380
tools/verifySWDBG.js Normal file
View File

@ -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);