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:
parent
9b45f7ba6f
commit
ab6b2ce9fc
Binary file not shown.
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 294 KiB |
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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." }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -103,3 +103,5 @@ registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGa
|
|||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75 });
|
||||
registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 });
|
||||
registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77 });
|
||||
registerGame({ slug: 'swdbg', name: 'Star Wars Deckbuilder', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
// Dungeon Boss AI — synchronous heuristics over the engine's public view
|
||||
// (publicView in DungeonBossLogic redacts rival hands and pending builds; the
|
||||
// AI never sees hidden information). One handler per decision kind, routed
|
||||
// through decide(). Skill 1–5 follows the AzulAI shape: widening top-N with
|
||||
// score noise and a blunder chance at low skill.
|
||||
|
||||
import {
|
||||
BOSSES, ROOMS, SPELLS, HEROES, CLASSES,
|
||||
heroSouls, roomDef, spellDef, heroDef,
|
||||
} from './DungeonBossData.js';
|
||||
import {
|
||||
legalBuilds, windowActions, treasureCount, dungeonDamage, baitTargets,
|
||||
} from './DungeonBossLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { topN: 5, blunder: 0.40, noise: 10, actChance: 0.25, delay: [700, 1200] },
|
||||
2: { topN: 4, blunder: 0.22, noise: 7, actChance: 0.45, delay: [650, 1100] },
|
||||
3: { topN: 3, blunder: 0.10, noise: 4, actChance: 0.70, delay: [600, 1000] },
|
||||
4: { topN: 2, blunder: 0.03, noise: 2, actChance: 0.90, delay: [520, 900] },
|
||||
5: { topN: 1, blunder: 0.00, noise: 0, actChance: 1.00, delay: [440, 820] },
|
||||
};
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
// rnd: a plain ()=>[0,1) function (seeded in verify, Math.random in the scene).
|
||||
function pickScored(cands, skill, rnd) {
|
||||
const prof = profileFor(skill);
|
||||
if (!cands.length) return null;
|
||||
if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice;
|
||||
const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise }));
|
||||
jittered.sort((a, b) => b.s - a.s);
|
||||
const n = Math.min(prof.topN, jittered.length);
|
||||
return jittered[Math.floor(rnd() * n)].choice;
|
||||
}
|
||||
|
||||
const me = (view, seat) => view.players[seat];
|
||||
const rivals = (view, seat) => view.players.filter((p) => p.alive && p.seat !== seat);
|
||||
const soulLeader = (view, seat) => rivals(view, seat).sort((a, b) => b.souls - a.souls)[0] || null;
|
||||
const canKill = (view, seat, hero) => dungeonDamage(view, seat) >= hero.hp;
|
||||
|
||||
function roomValue(def) {
|
||||
const icons = Object.values(def.treasure || {}).reduce((a, b) => a + b, 0);
|
||||
return def.dmg * 2 + icons * 1.5 + (def.effects || []).length + (def.passive ? 1 : 0);
|
||||
}
|
||||
function handCardValue(inst) {
|
||||
const r = ROOMS[inst.id];
|
||||
if (r) return roomValue(r) + (r.advanced ? 1 : 0);
|
||||
return 5; // spells are versatile — keep them over weak rooms
|
||||
}
|
||||
|
||||
// ── Decision handlers ────────────────────────────────────────────────────────
|
||||
|
||||
function chooseSetupDiscard(view, seat) {
|
||||
const p = me(view, seat);
|
||||
const all = [...p.hand.rooms, ...p.hand.spells]
|
||||
.map((c) => ({ uid: c.uid, v: handCardValue(c) + (ROOMS[c.id] && ROOMS[c.id].advanced ? -2 : 0) }))
|
||||
.sort((a, b) => a.v - b.v);
|
||||
// Never discard down to zero ordinary rooms — the setup build needs one.
|
||||
const isOrdinary = (uid) => {
|
||||
const c = p.hand.rooms.find((x) => x.uid === uid);
|
||||
return c && !ROOMS[c.id].advanced;
|
||||
};
|
||||
const picked = [];
|
||||
for (const cand of all) {
|
||||
if (picked.length === 2) break;
|
||||
const remainingOrdinary = p.hand.rooms.filter((c) => !ROOMS[c.id].advanced
|
||||
&& !picked.includes(c.uid) && c.uid !== cand.uid).length;
|
||||
if (isOrdinary(cand.uid) && remainingOrdinary === 0) continue;
|
||||
picked.push(cand.uid);
|
||||
}
|
||||
// Fallback (hand was nearly all ordinary): take the two cheapest regardless.
|
||||
while (picked.length < 2) {
|
||||
const next = all.find((c) => !picked.includes(c.uid));
|
||||
picked.push(next.uid);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
function scoreBuild(view, seat, b) {
|
||||
const p = me(view, seat);
|
||||
const def = roomDef(p.hand.rooms.find((c) => c.uid === b.roomUid));
|
||||
let s = 0;
|
||||
const isNew = b.slotIdx >= p.dungeon.length;
|
||||
// Value of the room itself, minus what a build-over buries.
|
||||
s += def.dmg * 1.5 + Object.values(def.treasure || {}).reduce((a, c) => a + c, 0);
|
||||
if (!isNew) {
|
||||
const buried = roomDef(p.dungeon[b.slotIdx].room);
|
||||
s -= roomValue(buried) * 0.6;
|
||||
if (buried.dmg <= 1 && !buried.passive) s += 2; // upgrading a weak room is fine
|
||||
} else {
|
||||
s += 2;
|
||||
if (p.dungeon.length === 4 && !p.boss.leveledUp) s += 6; // level-up on the 5th room
|
||||
}
|
||||
if (def.advanced) s += 2;
|
||||
// Bait consequences: compare who I attract (and whether I can kill them)
|
||||
// before and after this build — existing icons already pull heroes, so
|
||||
// exposure to unkillable heroes matters as much as new pulls.
|
||||
const curDmg = dungeonDamage(view, seat);
|
||||
const projDmg = curDmg + def.dmg + (isNew ? 0 : -roomDef(p.dungeon[b.slotIdx].room).dmg);
|
||||
for (const hero of view.town) {
|
||||
const h = heroDef(hero);
|
||||
if (h.fool) continue;
|
||||
const curMine = treasureCount(view, seat, h.cls);
|
||||
const mine = curMine
|
||||
+ (def.treasure?.[h.cls] || 0)
|
||||
- (isNew ? 0 : ((roomDef(p.dungeon[b.slotIdx].room).treasure || {})[h.cls] || 0));
|
||||
const bestRival = Math.max(0, ...rivals(view, seat).map((r) => treasureCount(view, r.seat, h.cls)));
|
||||
const wasMax = curMine > bestRival;
|
||||
const nowMax = mine > bestRival;
|
||||
const killCur = curDmg >= hero.hp;
|
||||
const killNow = projDmg >= hero.hp;
|
||||
if (!wasMax && nowMax) {
|
||||
s += killNow ? 4 + heroSouls(h) * 3 : -(6 + (h.epic ? 6 : 0));
|
||||
} else if (wasMax && nowMax) {
|
||||
if (!killCur && killNow) s += 8; // this build saves me a wound
|
||||
else if (!killNow) s -= 2; // still doomed; prefer damage elsewhere
|
||||
} else if (wasMax && !nowMax) {
|
||||
s += killCur ? -4 : 5; // dodged an unkillable hero / lost a soul
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function chooseBuild(view, seat, skill, rnd, decision) {
|
||||
const builds = legalBuilds(view, seat);
|
||||
if (decision.setup) {
|
||||
let best = null; let bestV = -1e9;
|
||||
for (const b of builds) {
|
||||
const def = roomDef(me(view, seat).hand.rooms.find((c) => c.uid === b.roomUid));
|
||||
if (def.advanced) continue; // advanced can't open a dungeon anyway
|
||||
const v = roomValue(def);
|
||||
if (v > bestV) { bestV = v; best = b; }
|
||||
}
|
||||
return best || builds[0] || null;
|
||||
}
|
||||
const cands = builds.map((b) => ({ choice: b, s: scoreBuild(view, seat, b) }));
|
||||
cands.push({ choice: null, s: -1 }); // passing is rarely right
|
||||
return pickScored(cands, skill, rnd);
|
||||
}
|
||||
|
||||
// One scored list across castable spells + activatable rooms + pass.
|
||||
function chooseWindow(view, seat, skill, rnd, decision) {
|
||||
const prof = profileFor(skill);
|
||||
const acts = windowActions(view, seat);
|
||||
if ((!acts.spells.length && !acts.rooms.length) || rnd() > prof.actChance) return { pass: true };
|
||||
const p = me(view, seat);
|
||||
const isAdv = decision.window === 'advStart';
|
||||
const myTurn = isAdv && decision.advSeat === seat;
|
||||
const leader = soulLeader(view, seat);
|
||||
const cands = [{ choice: { pass: true }, s: 1 }];
|
||||
|
||||
const survivor = (who) => who.entrance.find((h) => h.hp > dungeonDamage(view, who.seat));
|
||||
const dying = (who) => who.entrance.find((h) => h.hp <= dungeonDamage(view, who.seat));
|
||||
|
||||
for (const sp of acts.spells) {
|
||||
const def = spellDef({ id: sp.id });
|
||||
const op = def.op.op;
|
||||
const push = (target, s) => cands.push({ choice: { spellUid: sp.uid, target }, s });
|
||||
if (op === 'dmgBoostRoom' && myTurn && survivor(p)) {
|
||||
// Boost my biggest matching room if it flips a survivor to a kill.
|
||||
const own = (sp.targets || []).filter((t) => t.seat === seat);
|
||||
const best = own.sort((a, b) => roomDef(p.dungeon[b.slotIdx].room).dmg - roomDef(p.dungeon[a.slotIdx].room).dmg)[0];
|
||||
if (best && survivor(p).hp <= dungeonDamage(view, seat) + def.op.amount) push(best, 12);
|
||||
else if (best) push(best, 3);
|
||||
} else if (op === 'healHero' && isAdv && !myTurn && leader) {
|
||||
const t = (sp.targets || []).find((t2) => {
|
||||
const owner = view.players.find((q) => q.entrance.some((h) => h.uid === t2.uid));
|
||||
if (!owner || owner.seat === seat) return false;
|
||||
const hero = owner.entrance.find((h) => h.uid === t2.uid);
|
||||
return hero.hp <= dungeonDamage(view, owner.seat) && hero.hp + def.op.amount > dungeonDamage(view, owner.seat);
|
||||
});
|
||||
if (t) push(t, 11);
|
||||
} else if (op === 'damageHero' && myTurn) {
|
||||
const sv = survivor(p);
|
||||
if (sv && sv.hp - p.dungeon.length <= dungeonDamage(view, seat)) push({ kind: 'hero', uid: sv.uid }, 11);
|
||||
} else if (op === 'teleportHero' && myTurn) {
|
||||
const sv = survivor(p);
|
||||
if (sv && sv.hp <= dungeonDamage(view, seat) * 2) push({ kind: 'hero', uid: sv.uid }, 10);
|
||||
} else if (op === 'fearHero' && myTurn) {
|
||||
const sv = survivor(p);
|
||||
if (sv) push({ kind: 'hero', uid: sv.uid }, 9); // shoo away a hero I can't kill
|
||||
} else if (op === 'deactivateRoom' && !isAdv && leader) {
|
||||
const t = (sp.targets || []).filter((t2) => t2.seat === leader.seat)
|
||||
.sort((a, b) => roomDef(leader.dungeon[b.slotIdx].room).dmg - roomDef(leader.dungeon[a.slotIdx].room).dmg)[0];
|
||||
if (t) push(t, 6);
|
||||
} else if (op === 'lureHero') {
|
||||
const t = (sp.targets || []).find((t2) => {
|
||||
const hero = view.town.find((h) => h.uid === t2.uid);
|
||||
return hero && canKill(view, seat, hero);
|
||||
});
|
||||
if (t) push(t, 8 + (heroDef(view.town.find((h) => h.uid === t.uid)).epic ? 4 : 0));
|
||||
} else if (op === 'resurrectAttack' && leader) {
|
||||
const t = (sp.targets || []).find((t2) => {
|
||||
if (t2.seat !== leader.seat) return false;
|
||||
const hero = leader.soulCards.find((h) => h.uid === t2.uid);
|
||||
return hero && hero.hpMax + (def.op.hpBonus || 0) > dungeonDamage(view, leader.seat);
|
||||
});
|
||||
if (t) push(t, 9);
|
||||
} else if (op === 'blockHeroes') {
|
||||
const t = (sp.targets || [])[0];
|
||||
if (t) push(t, 7);
|
||||
} else if (op === 'caveIn' && myTurn) {
|
||||
const sv = survivor(p);
|
||||
if (sv) {
|
||||
const t = (sp.targets || []).sort((a, b) =>
|
||||
roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0];
|
||||
if (t) push(t, 8);
|
||||
}
|
||||
} else if (op === 'sacrificeSoulDraw') {
|
||||
if (p.hand.spells.length <= 1 && p.souls > 2 && p.souls < 9) push((sp.targets || [])[0], 3);
|
||||
} else if (op === 'handReset') {
|
||||
if (p.hand.rooms.length + p.hand.spells.length <= 2) push(null, 4);
|
||||
} else if (op === 'noBuildRound') {
|
||||
const rivalNearLevel = rivals(view, seat).some((r) => r.dungeon.length === 4 && !r.boss.leveledUp);
|
||||
if (rivalNearLevel) push(null, 5);
|
||||
} else if (op === 'extraBuild') {
|
||||
if (p.hand.rooms.length) push(null, 6);
|
||||
} else if (op === 'dmgBoostAllRooms' && myTurn && survivor(p)) {
|
||||
const t = (sp.targets || [])[0];
|
||||
if (t) push(t, 6);
|
||||
}
|
||||
}
|
||||
for (const ra of acts.rooms) {
|
||||
const def = roomDef(p.dungeon[ra.slotIdx].room);
|
||||
const eff = (def.effects || [])[ra.effIdx];
|
||||
const push = (target, s, extra = {}) => cands.push({ choice: { slotIdx: ra.slotIdx, effIdx: ra.effIdx, target, ...extra }, s });
|
||||
if (eff.op === 'pitTrap' && myTurn) {
|
||||
const sv = survivor(p);
|
||||
if (sv) push(null, 12); // guaranteed kill beats keeping a 1-damage trap
|
||||
} else if (eff.op === 'rampTrap' && myTurn) {
|
||||
const sv = survivor(p);
|
||||
if (sv && sv.hp <= dungeonDamage(view, seat) + 5) {
|
||||
const t = (ra.targets || []).sort((a, b) =>
|
||||
roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0];
|
||||
if (t) push(t, 10);
|
||||
}
|
||||
} else if (eff.op === 'dmgBoostAllRooms' && myTurn && survivor(p)) {
|
||||
const t = (ra.targets || []).sort((a, b) =>
|
||||
roomValue(roomDef(p.dungeon[a.slotIdx].room)) - roomValue(roomDef(p.dungeon[b.slotIdx].room)))[0];
|
||||
if (t) push(t, 9);
|
||||
} else if (eff.op === 'doubleTreasure' && !isAdv) {
|
||||
const killable = view.town.filter((h) => !heroDef(h).fool && canKill(view, seat, h));
|
||||
const bt = baitTargets(view);
|
||||
if (killable.some((h) => bt[h.uid] !== seat)) push(null, 7);
|
||||
} else if (eff.op === 'drawSpell' && eff.cost && eff.cost.discardMonsterRoom) {
|
||||
if (p.hand.rooms.filter((c) => roomDef(c).type === 'monster').length > 2) push(null, 2);
|
||||
} else if (eff.op === 'recoverDiscard') {
|
||||
const bestRoom = (ra.targets || []).map((t) => view.roomDiscard.find((c) => c.uid === t.uid))
|
||||
.filter(Boolean).sort((a, b) => roomValue(roomDef(b)) - roomValue(roomDef(a)))[0];
|
||||
if (bestRoom && roomValue(roomDef(bestRoom)) >= 8 && p.hand.rooms.length >= (eff.cost?.discardRooms || 0) + 1) {
|
||||
push({ kind: 'card', seat: -1, uid: bestRoom.uid }, 3);
|
||||
}
|
||||
} else if (eff.op === 'opponentDiscardRandom' && leader) {
|
||||
const t = (ra.targets || []).find((t2) => t2.seat === leader.seat);
|
||||
if (t && p.souls >= 6) push(t, 2);
|
||||
} else if (eff.op === 'resurrectAttack' && leader) {
|
||||
const t = (ra.targets || []).find((t2) => {
|
||||
if (t2.seat !== leader.seat) return false;
|
||||
const hero = leader.soulCards.find((h) => h.uid === t2.uid);
|
||||
return hero && hero.hpMax > dungeonDamage(view, leader.seat);
|
||||
});
|
||||
if (t) push(t, 8);
|
||||
}
|
||||
}
|
||||
return pickScored(cands, skill, rnd) || { pass: true };
|
||||
}
|
||||
|
||||
function chooseReact(view, seat, skill, rnd, decision) {
|
||||
const prof = profileFor(skill);
|
||||
if (rnd() > prof.actChance) return null;
|
||||
const t = decision.target;
|
||||
const aimedAtMe = t && (t.seat === seat
|
||||
|| (t.kind === 'hero' && me(view, seat).entrance.some((h) => h.uid === t.uid)));
|
||||
const casterIsLeader = soulLeader(view, seat) && decision.casterSeat === soulLeader(view, seat).seat;
|
||||
if (!aimedAtMe && !(casterIsLeader && rnd() < 0.3)) return null;
|
||||
const p = me(view, seat);
|
||||
// Prefer the All-Seeing Eye (costs a spare spell) over burning Counterspell.
|
||||
for (let slotIdx = 0; slotIdx < p.dungeon.length; slotIdx++) {
|
||||
const slot = p.dungeon[slotIdx];
|
||||
if (slot.deactivated) continue;
|
||||
const effIdx = (roomDef(slot.room).effects || []).findIndex((e) => e.trigger === 'reaction');
|
||||
if (effIdx >= 0 && !slot.usedOnce[effIdx] && p.hand.spells.length > 1) {
|
||||
const spare = p.hand.spells.find((c) => spellDef(c).op.op !== 'counterSpell') || p.hand.spells[0];
|
||||
return { type: 'allseeingeye', slotIdx, discardSpellUid: spare.uid };
|
||||
}
|
||||
}
|
||||
const cs = p.hand.spells.find((c) => spellDef(c).op.op === 'counterSpell');
|
||||
if (cs && aimedAtMe) return { type: 'counterspell', spellUid: cs.uid };
|
||||
return null;
|
||||
}
|
||||
|
||||
function chooseTarget(view, seat, skill, rnd, decision) {
|
||||
const cands = decision.candidates || [];
|
||||
const p = me(view, seat);
|
||||
const best = (scoreFn) => {
|
||||
let top = null; let topS = -1e9;
|
||||
for (const c of cands) { const s = scoreFn(c); if (s > topS) { topS = s; top = c; } }
|
||||
return { top, topS };
|
||||
};
|
||||
switch (decision.op) {
|
||||
case 'destroyOwnRoom': // forced (Robobo) — lose the least
|
||||
return best((c) => -roomValue(roomDef(p.dungeon[c.slotIdx].room))).top;
|
||||
case 'recoverDiscard': {
|
||||
const { top } = best((c) => {
|
||||
const r = view.roomDiscard.find((x) => x.uid === c.uid);
|
||||
if (r) return roomValue(roomDef(r));
|
||||
return 4; // an unknown spell is a decent pull
|
||||
});
|
||||
return top;
|
||||
}
|
||||
case 'revealTake': {
|
||||
// Level-up peek: real hands are exposed by the candidate list via uids the
|
||||
// engine validated; without card identity here, take from the soul leader.
|
||||
const leader = soulLeader(view, seat);
|
||||
return best((c) => (leader && c.seat === leader.seat ? 2 : 1) + rnd()).top;
|
||||
}
|
||||
case 'stealRandom': case 'opponentDiscardRandom': {
|
||||
const leader = soulLeader(view, seat);
|
||||
return best((c) => (leader && c.seat === leader.seat ? 2 : 1)).top;
|
||||
}
|
||||
case 'tutorAdvanced':
|
||||
return best((c) => {
|
||||
const inst = [...(view.roomDiscard || [])].find((x) => x.uid === c.roomUid);
|
||||
return inst ? roomValue(roomDef(inst)) : 5 + rnd();
|
||||
}).top;
|
||||
case 'killHeroTown':
|
||||
return best((c) => {
|
||||
const hero = view.town.find((h) => h.uid === c.uid);
|
||||
return hero ? heroSouls(heroDef(hero)) * 4 + hero.hp : 0;
|
||||
}).top;
|
||||
case 'lureHero': {
|
||||
const { top, topS } = best((c) => {
|
||||
const hero = view.town.find((h) => h.uid === c.uid)
|
||||
|| [(view.deckCounts ? null : null)].filter(Boolean)[0];
|
||||
if (!hero) return -1; // deck heroes are unknown to the view; skip them
|
||||
const kill = canKill(view, seat, hero);
|
||||
return (kill ? 8 : -8) + heroSouls(heroDef(hero)) * 2;
|
||||
});
|
||||
if (decision.optional && topS <= 0) return null;
|
||||
return top;
|
||||
}
|
||||
case 'swapRooms':
|
||||
return decision.optional ? null : cands[0];
|
||||
default: {
|
||||
const pick = cands[Math.floor(rnd() * cands.length)];
|
||||
return decision.optional && rnd() < 0.5 ? pick : (decision.optional ? pick : pick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseDiscard(view, seat, decision) {
|
||||
const p = me(view, seat);
|
||||
const pool = decision.cardType === 'spell' ? p.hand.spells : p.hand.rooms;
|
||||
return pool.slice().sort((a, b) => handCardValue(a) - handCardValue(b))
|
||||
.slice(0, decision.n).map((c) => c.uid);
|
||||
}
|
||||
|
||||
function chooseRoomDraw(view, seat) {
|
||||
return me(view, seat).hand.spells.length < 2 ? 'spell' : 'room';
|
||||
}
|
||||
|
||||
// ── Router ──────────────────────────────────────────────────────────────────
|
||||
// Returns the argument for the matching act* mutator:
|
||||
// setupDiscard → [uidA, uidB] · build → {roomUid,slotIdx}|null ·
|
||||
// window → {spellUid,target}|{slotIdx,effIdx,target,costUids}|{pass:true} ·
|
||||
// react → response|null · target → targetRef|null · discard → [uids] ·
|
||||
// roomDraw → 'room'|'spell'
|
||||
export function decide(view, decision, skill, rnd = Math.random) {
|
||||
const seat = decision.seat;
|
||||
switch (decision.kind) {
|
||||
case 'setupDiscard': return chooseSetupDiscard(view, seat);
|
||||
case 'build': return chooseBuild(view, seat, skill, rnd, decision);
|
||||
case 'window': return chooseWindow(view, seat, skill, rnd, decision);
|
||||
case 'react': return chooseReact(view, seat, skill, rnd, decision);
|
||||
case 'target': return chooseTarget(view, seat, skill, rnd, decision);
|
||||
case 'discard': return chooseDiscard(view, seat, decision);
|
||||
case 'roomDraw': return chooseRoomDraw(view, seat);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
// Dungeon Boss — card content (Boss Monster base set: real names/stats, rules text our own wording).
|
||||
// Effects are machine-encoded; `text` is display-only. The engine consumes the encodings, so the
|
||||
// two must stay in sync — verifyDungeonBoss.js cross-checks ops against the supported enum.
|
||||
|
||||
export const CLASSES = ['fighter', 'mage', 'thief', 'cleric'];
|
||||
|
||||
export const CLASS_INFO = {
|
||||
fighter: { label: 'Fighter', color: 0xc0392b, glyph: 'sword' },
|
||||
mage: { label: 'Mage', color: 0x2e6fd8, glyph: 'tome' },
|
||||
thief: { label: 'Thief', color: 0xd9a520, glyph: 'coins' },
|
||||
cleric: { label: 'Cleric', color: 0x2ea08c, glyph: 'ankh' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bosses — one-time Level Up ability when the 5th room is built.
|
||||
// Turn order: highest XP first. End-of-game soul tie: LOWEST XP wins.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const BOSSES = {
|
||||
draculord: {
|
||||
name: 'Draculord', xp: 900, treasure: 'cleric',
|
||||
levelUp: { op: 'revealTake' },
|
||||
text: 'Level Up: Look at an opponent’s hand and take a card of your choice.',
|
||||
},
|
||||
cleopatra: {
|
||||
name: 'Cleopatra', xp: 850, treasure: 'thief',
|
||||
levelUp: { op: 'tutorAdvanced', roomType: 'trap' },
|
||||
text: 'Level Up: Fetch an Advanced Trap Room from the deck or discard and build it at once.',
|
||||
},
|
||||
kingcroak: {
|
||||
name: 'King Croak', xp: 800, treasure: 'fighter',
|
||||
levelUp: { op: 'tutorAdvanced', roomType: 'monster' },
|
||||
text: 'Level Up: Fetch an Advanced Monster Room from the deck or discard and build it at once.',
|
||||
},
|
||||
xyzax: {
|
||||
name: 'Xyzax', xp: 750, treasure: 'cleric',
|
||||
levelUp: { op: 'recoverDiscard', pool: 'any', n: 2 },
|
||||
text: 'Level Up: Pick two cards from the discard piles and add them to your hand.',
|
||||
},
|
||||
cerebellus: {
|
||||
name: 'Cerebellus', xp: 650, treasure: 'mage',
|
||||
levelUp: { op: 'drawThenDiscard', draw: 'spell', n: 3, discard: 'spell', d: 1 },
|
||||
text: 'Level Up: Draw three Spells, then discard one Spell.',
|
||||
},
|
||||
seducia: {
|
||||
name: 'Seducia', xp: 600, treasure: 'mage',
|
||||
levelUp: { op: 'lureHero', from: 'townOrDeck' },
|
||||
text: 'Level Up: Pick any Hero in town or in the Hero decks and drop it at your entrance.',
|
||||
},
|
||||
gorgona: {
|
||||
name: 'Gorgona', xp: 500, treasure: 'thief',
|
||||
levelUp: { op: 'killHeroTown' },
|
||||
text: 'Level Up: Turn one Hero in town to stone — score its soul immediately.',
|
||||
},
|
||||
robobo: {
|
||||
name: 'Robobo', xp: 400, treasure: 'fighter',
|
||||
levelUp: { op: 'wreckEachOpponent' },
|
||||
text: 'Level Up: Every opponent must demolish one room of their own dungeon.',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rooms. type: 'monster'|'trap'; advanced rooms may only be built over a room
|
||||
// sharing at least one treasure class. treasure: { class: count }.
|
||||
// Effect triggers:
|
||||
// onBuild — resolves when the room is revealed in the build flip
|
||||
// onHeroDieHere — once per round, when a hero dies in this room
|
||||
// turnStart — choice offered at the start of each round
|
||||
// activated — player action during a spell window (build/adventure); may have a cost
|
||||
// reaction — offered when an opponent casts a spell (cancel)
|
||||
// passive — computed directly by the engine (keyword field, no op)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const ROOMS = {
|
||||
// --- Cleric ---
|
||||
darkaltar: {
|
||||
name: 'Dark Altar', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 2 }, count: 3,
|
||||
effects: [{ trigger: 'activated', window: 'any', cost: { destroySelf: true }, op: 'recoverDiscard', pool: 'any', n: 1 }],
|
||||
text: 'Sacrifice this room: reclaim any one card from a discard pile.',
|
||||
},
|
||||
opengrave: {
|
||||
name: 'Open Grave', type: 'trap', advanced: false, dmg: 2, treasure: { cleric: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onHeroDieHere', op: 'recoverDiscard', pool: 'room', n: 1, auto: true }],
|
||||
text: 'When a hero dies here (once a round), reclaim a Room card from the discard pile.',
|
||||
},
|
||||
specterssanctum: {
|
||||
name: "Specter's Sanctum", type: 'monster', advanced: false, dmg: 2, treasure: { cleric: 1 }, count: 3,
|
||||
effects: [{ trigger: 'onBuild', op: 'opponentDiscardRandom', filter: 'spell' }],
|
||||
text: 'On build: a rival of your choice loses a random Spell.',
|
||||
},
|
||||
succubusspa: {
|
||||
name: 'Succubus Spa', type: 'monster', advanced: false, dmg: 1, treasure: { cleric: 1 }, count: 3,
|
||||
effects: [{ trigger: 'onHeroDieHere', op: 'stealRandom', filter: 'any' }],
|
||||
text: 'When a hero dies here (once a round), snatch a random card from a rival’s hand.',
|
||||
},
|
||||
dracolichlair: {
|
||||
name: 'Dracolich Lair', type: 'monster', advanced: true, dmg: 3, treasure: { cleric: 1 }, count: 2,
|
||||
effects: [{ trigger: 'activated', window: 'any', oncePerTurn: true, cost: { discardRooms: 2 }, op: 'recoverDiscard', pool: 'room', n: 1 }],
|
||||
text: 'Once a round, toss two Room cards to reclaim any Room card from the discard pile.',
|
||||
},
|
||||
vampirebordello: {
|
||||
name: 'Vampire Bordello', type: 'monster', advanced: true, dmg: 3, treasure: { cleric: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onHeroDieHere', op: 'healWound' }],
|
||||
text: 'When a hero dies here (once a round), mend one of your wounds.',
|
||||
},
|
||||
// --- Fighter ---
|
||||
goblinarmory: {
|
||||
name: 'Goblin Armory', type: 'monster', advanced: false, dmg: 1, treasure: { fighter: 2 }, count: 3,
|
||||
passive: 'adjacentMonsterBoost',
|
||||
text: 'Monster Rooms next to this one strike for +1 damage.',
|
||||
},
|
||||
golemfactory: {
|
||||
name: 'Golem Factory', type: 'monster', advanced: false, dmg: 2, treasure: { fighter: 1 }, count: 3,
|
||||
effects: [{ trigger: 'onHeroDieHere', op: 'drawRoom', n: 1 }],
|
||||
text: 'When a hero dies here (once a round), draw a Room card.',
|
||||
},
|
||||
minotaursmaze: {
|
||||
name: "Minotaur's Maze", type: 'monster', advanced: false, dmg: 0, treasure: { fighter: 1 }, count: 2,
|
||||
passive: 'minotaurBounce',
|
||||
text: 'The first hero through each round is shoved back into the room it just left.',
|
||||
},
|
||||
neanderthalcave: {
|
||||
name: 'Neanderthal Cave', type: 'monster', advanced: false, dmg: 3, treasure: { fighter: 1 }, count: 3,
|
||||
passive: 'noAdvancedOnTop',
|
||||
text: 'No Advanced Room may ever be built over this cave.',
|
||||
},
|
||||
beastmenagerie: {
|
||||
name: 'Beast Menagerie', type: 'monster', advanced: true, dmg: 4, treasure: { fighter: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onBuildOtherMonster', op: 'drawRoom', n: 1, oncePerTurn: true }],
|
||||
text: 'Once a round, when you build another Monster Room, draw a Room card.',
|
||||
},
|
||||
monstersballroom: {
|
||||
name: "Monster's Ballroom", type: 'monster', advanced: true, dmg: 0, treasure: { fighter: 1 }, count: 2,
|
||||
passive: 'ballroomDamage',
|
||||
text: 'Deals damage equal to the number of Monster Rooms in your dungeon.',
|
||||
},
|
||||
// --- Mage ---
|
||||
brainsuckerhive: {
|
||||
name: 'Brainsucker Hive', type: 'monster', advanced: false, dmg: 2, treasure: { mage: 1 }, count: 3,
|
||||
effects: [{ trigger: 'onHeroDieHere', op: 'drawSpell', n: 1 }],
|
||||
text: 'When a hero dies here (once a round), draw a Spell card.',
|
||||
},
|
||||
darklaboratory: {
|
||||
name: 'Dark Laboratory', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 2 }, count: 3,
|
||||
effects: [{ trigger: 'onBuild', op: 'drawThenDiscard', draw: 'spell', n: 2, discard: 'spell', d: 1 }],
|
||||
text: 'On build: draw two Spells, then discard one Spell.',
|
||||
},
|
||||
hauntedlibrary: {
|
||||
name: 'Haunted Library', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 1 }, count: 2,
|
||||
passive: 'librarySpellDraw',
|
||||
text: 'At the start of each round you may draw a Spell instead of a Room.',
|
||||
},
|
||||
witchskitchen: {
|
||||
name: "Witch's Kitchen", type: 'monster', advanced: false, dmg: 1, treasure: { mage: 1 }, count: 3,
|
||||
effects: [{ trigger: 'activated', window: 'any', oncePerTurn: true, cost: { discardMonsterRoom: true }, op: 'drawSpell', n: 1 }],
|
||||
text: 'Once a round, toss a Monster Room card to draw a Spell card.',
|
||||
},
|
||||
allseeingeye: {
|
||||
name: 'All-Seeing Eye', type: 'trap', advanced: true, dmg: 3, treasure: { mage: 1 }, count: 2,
|
||||
effects: [{ trigger: 'reaction', oncePerTurn: true, cost: { discardSpell: true }, op: 'counterSpell' }],
|
||||
text: 'Once a round, toss a Spell card to cancel a rival’s spell as it is cast.',
|
||||
},
|
||||
ligersden: {
|
||||
name: "Liger's Den", type: 'monster', advanced: true, dmg: 2, treasure: { mage: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onCastSpell', op: 'drawSpell', n: 1, oncePerTurn: true }],
|
||||
text: 'Once a round, when you cast a Spell, draw a Spell card.',
|
||||
},
|
||||
// --- Thief ---
|
||||
bottomlesspit: {
|
||||
name: 'Bottomless Pit', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 3,
|
||||
effects: [{ trigger: 'activated', window: 'adventure', cost: { destroySelf: true }, op: 'pitTrap' }],
|
||||
text: 'Sacrifice this room: the next hero to enter it this round plummets and dies.',
|
||||
},
|
||||
boulderramp: {
|
||||
name: 'Boulder Ramp', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 2,
|
||||
effects: [{ trigger: 'activated', window: 'adventure', cost: { destroyOther: true }, op: 'rampTrap' }],
|
||||
text: 'Demolish another of your rooms: the next hero entering here takes 5 extra damage.',
|
||||
},
|
||||
dizzygashallway: {
|
||||
name: 'Dizzygas Hallway', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 1 }, count: 3,
|
||||
passive: 'dizzygas',
|
||||
text: 'If the room after this one is a Trap Room, that room strikes for +2 damage.',
|
||||
},
|
||||
jackpotstash: {
|
||||
name: 'Jackpot Stash', type: 'trap', advanced: false, dmg: 1, treasure: { thief: 2 }, count: 3,
|
||||
effects: [{ trigger: 'activated', window: 'build', cost: { destroySelf: true }, op: 'doubleTreasure' }],
|
||||
text: 'Sacrifice this room: your rooms’ treasure counts double until end of round.',
|
||||
},
|
||||
recyclingcenter: {
|
||||
name: 'Recycling Center', type: 'trap', advanced: true, dmg: 3, treasure: { thief: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onOwnRoomDestroyed', op: 'drawRoom', n: 2 }],
|
||||
text: 'Whenever another of your rooms is destroyed, draw two Room cards.',
|
||||
},
|
||||
thecrushinator: {
|
||||
name: 'The Crushinator', type: 'trap', advanced: true, dmg: 2, treasure: { thief: 1 }, count: 2,
|
||||
effects: [{ trigger: 'activated', window: 'adventure', oncePerTurn: true, cost: { destroyOther: true }, op: 'dmgBoostAllRooms', amount: 2 }],
|
||||
text: 'Once a round, demolish another of your rooms: all your rooms strike for +2 until end of round.',
|
||||
},
|
||||
// --- Dual / hybrid treasure ---
|
||||
centipedetunnel: {
|
||||
name: 'Centipede Tunnel', type: 'monster', advanced: false, dmg: 1, treasure: { fighter: 1, mage: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onBuild', op: 'swapRooms', optional: true }],
|
||||
text: 'On build: you may swap the position of two rooms in any one dungeon.',
|
||||
},
|
||||
constructionzone: {
|
||||
name: 'Construction Zone', type: 'trap', advanced: false, dmg: 1, treasure: { fighter: 1, thief: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onBuild', op: 'extraBuild', optional: true }],
|
||||
text: 'On build: you may immediately build one more room.',
|
||||
},
|
||||
dragonhatchery: {
|
||||
name: 'Dragon Hatchery', type: 'monster', advanced: false, dmg: 0, treasure: { fighter: 1, mage: 1, thief: 1, cleric: 1 }, count: 3,
|
||||
text: 'Glitters with every kind of treasure — and guards none of it with teeth.',
|
||||
},
|
||||
mimicvault: {
|
||||
name: 'Mimic Vault', type: 'trap', advanced: false, dmg: 1, treasure: { mage: 1, thief: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onBuild', op: 'lureHero', from: 'town', filter: 'ordinary', optional: true }],
|
||||
text: 'On build: drag one ordinary Hero from town to your entrance.',
|
||||
},
|
||||
monstrousmonument: {
|
||||
name: 'Monstrous Monument', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 1, fighter: 1 }, count: 2,
|
||||
effects: [{ trigger: 'onBuild', op: 'recoverDiscard', pool: 'monsterRoom', n: 1 }],
|
||||
text: 'On build: reclaim a Monster Room card from the discard pile.',
|
||||
},
|
||||
torturechamber: {
|
||||
name: 'Torture Chamber', type: 'trap', advanced: false, dmg: 1, treasure: { cleric: 1, thief: 1 }, count: 2,
|
||||
effects: [{ trigger: 'activated', window: 'any', cost: { destroySelf: true }, op: 'opponentDiscardRandom', filter: 'room' }],
|
||||
text: 'Sacrifice this room: a rival of your choice loses a random Room card.',
|
||||
},
|
||||
zombieprison: {
|
||||
name: 'Zombie Prison', type: 'monster', advanced: false, dmg: 1, treasure: { cleric: 1, mage: 1 }, count: 2,
|
||||
effects: [{ trigger: 'activated', window: 'build', cost: { destroySelf: true }, op: 'resurrectAttack', hpBonus: 0 }],
|
||||
text: 'Sacrifice this room: a dead hero in a rival’s score pile shambles back to their entrance.',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells. phase: 'build' | 'adventure' | 'both'.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const SPELLS = {
|
||||
annihilator: {
|
||||
name: 'Annihilator', phase: 'adventure', count: 2,
|
||||
op: { op: 'dmgBoostRoom', amount: 3, filter: 'trap' },
|
||||
text: 'One Trap Room strikes for +3 damage until end of round.',
|
||||
},
|
||||
assassin: {
|
||||
name: 'Assassin', phase: 'adventure', count: 3,
|
||||
op: { op: 'healHero', amount: 3, where: 'opponentDungeon' },
|
||||
text: 'A hero in a rival’s dungeon gains +3 health until end of round.',
|
||||
},
|
||||
cavein: {
|
||||
name: 'Cave-In', phase: 'adventure', count: 2,
|
||||
op: { op: 'caveIn' },
|
||||
text: 'Collapse one of your own rooms — any hero inside it is crushed.',
|
||||
},
|
||||
counterspell: {
|
||||
name: 'Counterspell', phase: 'both', count: 2,
|
||||
op: { op: 'counterSpell' },
|
||||
text: 'Cancel a spell the moment a rival casts it.',
|
||||
},
|
||||
exhaustion: {
|
||||
name: 'Exhaustion', phase: 'adventure', count: 1,
|
||||
op: { op: 'damageHero', amount: 'dungeonSize' },
|
||||
text: 'A hero in your dungeon takes damage equal to your room count.',
|
||||
},
|
||||
fear: {
|
||||
name: 'Fear', phase: 'adventure', count: 2,
|
||||
op: { op: 'fearHero' },
|
||||
text: 'A hero in any dungeon flees back to town.',
|
||||
},
|
||||
freeze: {
|
||||
name: 'Freeze', phase: 'both', count: 4,
|
||||
op: { op: 'deactivateRoom' },
|
||||
text: 'Ice over one room in any dungeon — no damage, treasure, or abilities until end of round.',
|
||||
},
|
||||
giantsize: {
|
||||
name: 'Giant Size', phase: 'adventure', count: 2,
|
||||
op: { op: 'dmgBoostRoom', amount: 3, filter: 'monster' },
|
||||
text: 'One Monster Room strikes for +3 damage until end of round.',
|
||||
},
|
||||
jeopardy: {
|
||||
name: 'Jeopardy', phase: 'both', count: 2,
|
||||
op: { op: 'handReset' },
|
||||
text: 'Every player discards their hand, then draws one Spell and two Rooms.',
|
||||
},
|
||||
koboldstrike: {
|
||||
name: 'Kobold Strike', phase: 'build', count: 1,
|
||||
op: { op: 'noBuildRound' },
|
||||
text: 'The kobolds walk out: nobody may build a room this round.',
|
||||
},
|
||||
motivation: {
|
||||
name: 'Motivation', phase: 'build', count: 2,
|
||||
op: { op: 'extraBuild', cond: 'fewerRooms' },
|
||||
text: 'If a rival has more rooms than you, you may build a second room this round.',
|
||||
},
|
||||
princessinperil: {
|
||||
name: 'Princess in Peril', phase: 'build', count: 2,
|
||||
op: { op: 'lureHero', from: 'town' },
|
||||
text: 'Stage a kidnapping: one Hero in town rushes straight to your entrance.',
|
||||
},
|
||||
soulharvest: {
|
||||
name: 'Soul Harvest', phase: 'both', count: 1,
|
||||
op: { op: 'sacrificeSoulDraw', n: 2 },
|
||||
text: 'Burn one scored soul to draw two Spell cards.',
|
||||
},
|
||||
teleportation: {
|
||||
name: 'Teleportation', phase: 'adventure', count: 2,
|
||||
op: { op: 'teleportHero' },
|
||||
text: 'Yank a hero in your dungeon back to your entrance — it walks the gauntlet again.',
|
||||
},
|
||||
trepidation: {
|
||||
name: 'Trepidation', phase: 'adventure', count: 1,
|
||||
op: { op: 'blockHeroes', cond: 'twoMoreSouls' },
|
||||
text: 'Pick a player at least two souls ahead of you: no hero enters their dungeon this round.',
|
||||
},
|
||||
zombieattack: {
|
||||
name: 'Zombie Attack', phase: 'build', count: 2,
|
||||
op: { op: 'resurrectAttack', hpBonus: 2 },
|
||||
text: 'A dead hero in a rival’s score pile rises with +2 health and storms their entrance.',
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heroes. Ordinary: 1 soul / 1 wound. Epic: 2 souls / 2 wounds.
|
||||
// The Fool is lured to the dungeon of the player with the FEWEST souls.
|
||||
// Hero deck sizing per player count (ordinary/epic per class) handled in buildDecks.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const HEROES = {
|
||||
// Ordinary — Cleric
|
||||
nick: { name: 'Nick the Masher', cls: 'cleric', hp: 4, epic: false },
|
||||
pugi: { name: 'Pugi the Druidess', cls: 'cleric', hp: 4, epic: false },
|
||||
acacia: { name: 'Acacia, Warrior of Light', cls: 'cleric', hp: 6, epic: false },
|
||||
charles: { name: 'Charles the Young', cls: 'cleric', hp: 6, epic: false },
|
||||
delatorious: { name: 'Delatorious', cls: 'cleric', hp: 8, epic: false },
|
||||
romero: { name: 'Romero, the Indigo Friar', cls: 'cleric', hp: 8, epic: false },
|
||||
// Ordinary — Fighter
|
||||
boden: { name: 'Boden the Pantless', cls: 'fighter', hp: 4, epic: false },
|
||||
jarek: { name: 'Jarek the Squire', cls: 'fighter', hp: 4, epic: false },
|
||||
firesbreath: { name: "Fire's Breath", cls: 'fighter', hp: 6, epic: false },
|
||||
samuraitom: { name: 'Samurai Tom', cls: 'fighter', hp: 6, epic: false },
|
||||
johnny: { name: 'Johnny of the Evening Watch', cls: 'fighter', hp: 8, epic: false },
|
||||
crystolalan: { name: "Crystol & A'lan of Gerd", cls: 'fighter', hp: 8, epic: false },
|
||||
// Ordinary — Mage
|
||||
tieg: { name: 'Tieg and the Magic Bubble', cls: 'mage', hp: 4, epic: false },
|
||||
brandork: { name: 'Brandork the Neverwrong', cls: 'mage', hp: 4, epic: false },
|
||||
mitchell: { name: 'Mitchell, the Judge', cls: 'mage', hp: 6, epic: false },
|
||||
kalish: { name: 'Kalish Ninefingers', cls: 'mage', hp: 6, epic: false },
|
||||
dartteon: { name: 'Dartteon, Elf Pyromancer', cls: 'mage', hp: 8, epic: false },
|
||||
koey: { name: 'Koey, the Last Dragon Mage', cls: 'mage', hp: 8, epic: false },
|
||||
// Ordinary — Thief
|
||||
joman: { name: 'Joman Chimm, Cutpurse', cls: 'thief', hp: 4, epic: false },
|
||||
lance: { name: 'Lance Uppercut', cls: 'thief', hp: 4, epic: false },
|
||||
jesta: { name: 'Jesta the Rogue', cls: 'thief', hp: 6, epic: false },
|
||||
kins: { name: 'Kins Klauski', cls: 'thief', hp: 6, epic: false },
|
||||
digby: { name: 'Sir Digby Apple', cls: 'thief', hp: 8, epic: false },
|
||||
jerome: { name: 'Jerome, Kung Fu Monkey', cls: 'thief', hp: 8, epic: false },
|
||||
// The Fool — any player count; baits to the FEWEST souls
|
||||
fool: { name: 'The Fool', cls: 'fool', hp: 2, epic: false, fool: true },
|
||||
// Epic — Cleric
|
||||
katelyn: { name: 'Katelyn, Angelic Healer', cls: 'cleric', hp: 11, epic: true },
|
||||
kerberos: { name: 'Kerberos Dirtbeard', cls: 'cleric', hp: 11, epic: true },
|
||||
vanette: { name: 'Lord Van Ette', cls: 'cleric', hp: 13, epic: true },
|
||||
jejune: { name: 'Jejune & Everlea', cls: 'cleric', hp: 13, epic: true },
|
||||
// Epic — Fighter
|
||||
frankov: { name: 'Frankov, the Envoy', cls: 'fighter', hp: 11, epic: true },
|
||||
nate: { name: 'Nate the Squidslayer', cls: 'fighter', hp: 11, epic: true },
|
||||
antonius: { name: 'Antonius, the Rune Knight', cls: 'fighter', hp: 13, epic: true },
|
||||
asmor: { name: 'Asmor the Aweless', cls: 'fighter', hp: 13, epic: true },
|
||||
// Epic — Mage
|
||||
tempros: { name: 'Tempros the Time Marauder', cls: 'mage', hp: 11, epic: true },
|
||||
chiakang: { name: 'Chia Kang, Warlock of Yu', cls: 'mage', hp: 11, epic: true },
|
||||
terric: { name: 'Terric Warhelm', cls: 'mage', hp: 13, epic: true },
|
||||
wayward: { name: 'Wayward, the Drifter', cls: 'mage', hp: 13, epic: true },
|
||||
// Epic — Thief
|
||||
hya: { name: 'Hya, Legendary Shinobi', cls: 'thief', hp: 11, epic: true },
|
||||
blackbeard: { name: 'Blackbeard Jake', cls: 'thief', hp: 11, epic: true },
|
||||
cecil: { name: 'Cecil Leoran, Factotum', cls: 'thief', hp: 13, epic: true },
|
||||
wallbanger: { name: 'Wallbanger Basketweaver', cls: 'thief', hp: 13, epic: true },
|
||||
};
|
||||
|
||||
export function heroSouls(def) { return def.epic ? 2 : 1; }
|
||||
export function heroWounds(def) { return def.epic ? 2 : 1; }
|
||||
|
||||
// Ordinary/epic heroes used per class, by player count (base-game rule).
|
||||
export const HERO_DECK_SIZING = {
|
||||
2: { ordinaryPerClass: 3, epicPerClass: 2 },
|
||||
3: { ordinaryPerClass: 4, epicPerClass: 3 },
|
||||
4: { ordinaryPerClass: 6, epicPerClass: 4 },
|
||||
};
|
||||
|
||||
// The full op enum — verify script checks every encoded effect against this.
|
||||
export const OPS = [
|
||||
'drawRoom', 'drawSpell', 'drawThenDiscard', 'recoverDiscard', 'revealTake', 'stealRandom',
|
||||
'opponentDiscardRandom', 'handReset', 'wreckEachOpponent', 'swapRooms', 'extraBuild',
|
||||
'noBuildRound', 'deactivateRoom', 'tutorAdvanced', 'dmgBoostRoom', 'dmgBoostAllRooms',
|
||||
'doubleTreasure', 'damageHero', 'healHero', 'killHeroTown', 'lureHero', 'fearHero',
|
||||
'teleportHero', 'resurrectAttack', 'blockHeroes', 'sacrificeSoulDraw', 'healWound',
|
||||
'counterSpell', 'pitTrap', 'rampTrap', 'caveIn',
|
||||
];
|
||||
|
||||
export const PASSIVES = [
|
||||
'adjacentMonsterBoost', 'minotaurBounce', 'noAdvancedOnTop', 'ballroomDamage',
|
||||
'librarySpellDraw', 'dizzygas',
|
||||
];
|
||||
|
||||
let uidCounter = 0;
|
||||
export function makeInstance(id) { return { uid: ++uidCounter, id }; }
|
||||
export function resetUids() { uidCounter = 0; }
|
||||
|
||||
// Build the four decks for a game. rng: makeRng instance from DungeonBossLogic.
|
||||
export function buildDecks(rng, nPlayers) {
|
||||
const rooms = [];
|
||||
for (const [id, def] of Object.entries(ROOMS)) {
|
||||
for (let i = 0; i < def.count; i++) rooms.push(makeInstance(id));
|
||||
}
|
||||
const spells = [];
|
||||
for (const [id, def] of Object.entries(SPELLS)) {
|
||||
for (let i = 0; i < def.count; i++) spells.push(makeInstance(id));
|
||||
}
|
||||
const sizing = HERO_DECK_SIZING[nPlayers] || HERO_DECK_SIZING[4];
|
||||
const heroes = [];
|
||||
const epics = [];
|
||||
for (const cls of CLASSES) {
|
||||
const ord = rng.shuffle(Object.keys(HEROES).filter((id) => !HEROES[id].epic && HEROES[id].cls === cls));
|
||||
const epi = rng.shuffle(Object.keys(HEROES).filter((id) => HEROES[id].epic && HEROES[id].cls === cls));
|
||||
for (const id of ord.slice(0, sizing.ordinaryPerClass)) heroes.push(instHero(id));
|
||||
for (const id of epi.slice(0, sizing.epicPerClass)) epics.push(instHero(id));
|
||||
}
|
||||
heroes.push(instHero('fool'));
|
||||
return {
|
||||
rooms: rng.shuffle(rooms),
|
||||
spells: rng.shuffle(spells),
|
||||
heroes: rng.shuffle(heroes),
|
||||
epics: rng.shuffle(epics),
|
||||
};
|
||||
}
|
||||
|
||||
function instHero(id) {
|
||||
const inst = makeInstance(id);
|
||||
inst.hp = HEROES[id].hp;
|
||||
inst.hpMax = HEROES[id].hp;
|
||||
return inst;
|
||||
}
|
||||
|
||||
export function roomDef(inst) { return ROOMS[inst.id]; }
|
||||
export function spellDef(inst) { return SPELLS[inst.id]; }
|
||||
export function heroDef(inst) { return HEROES[inst.id]; }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,252 @@
|
|||
# Dungeon Boss — Sprite / Art Spec
|
||||
|
||||
Everything in Dungeon Boss renders procedurally out of the box (frame, border,
|
||||
class-colored accents, name, rules text, treasure icons), so the game is fully
|
||||
playable with **no** art. This document lists the optional sprite sheets you
|
||||
can drop in to replace the placeholder art windows, with exact dimensions,
|
||||
layout, and frame-by-frame maps.
|
||||
|
||||
All art is wired through **`public/data/dungeonboss-artwork.json`**. The scene
|
||||
checks whether each sheet's texture is loaded; if it is, it uses your frame,
|
||||
otherwise it falls back to procedural drawing. You never need to touch code —
|
||||
just add a PNG, set its `path` in that JSON (all four are currently empty),
|
||||
and reload.
|
||||
|
||||
Frame numbering is **row-major, 0-based**: frame 0 is top-left, count
|
||||
left-to-right then down to the next row.
|
||||
|
||||
Only paint the illustration — the card frame, border, name plate, cost/damage
|
||||
badges, treasure-class icons, and rules text are all drawn procedurally on top
|
||||
of your art. Art is scaled to **fit** the art window (no cropping), so match
|
||||
the aspect ratio below for edge-to-edge fill; other aspects letterbox inside
|
||||
the window.
|
||||
|
||||
---
|
||||
|
||||
## 1. Room sheet — `dungeonboss-rooms.png`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/dungeonboss-rooms.png` |
|
||||
| **Frame size** | **300 × 200 px** (aspect 1.5 : 1, landscape — matches the room art window) |
|
||||
| **Sheet size** | **6 columns × 6 rows = 1800 × 1200 px** holds all 31 frames (5 spare) |
|
||||
| **Status** | ⛔ Procedural until you create it (then set `roomSheet.path` in the JSON) |
|
||||
| **JSON** | `roomSheet` (set `path`) + `rooms` map (already filled in for all 31 ids) |
|
||||
|
||||
Rooms tile into each player's dungeon wing (shown small, ~90×60 to ~168×112 on
|
||||
the board) and larger in the reward-row/inspect views (up to ~380×260). Two
|
||||
room **types** exist — `monster` (a creature guards the room) and `trap` (an
|
||||
environmental hazard) — lean into that flavor per room below.
|
||||
|
||||
### Frame map
|
||||
|
||||
| Frame | id | Name | Type | Advanced | Notes |
|
||||
|---:|---|---|---|---|---|
|
||||
| 0 | `darkaltar` | Dark Altar | trap | | cleric treasure |
|
||||
| 1 | `opengrave` | Open Grave | trap | | cleric treasure |
|
||||
| 2 | `specterssanctum` | Specter's Sanctum | monster | | cleric treasure |
|
||||
| 3 | `succubusspa` | Succubus Spa | monster | | cleric treasure |
|
||||
| 4 | `dracolichlair` | Dracolich Lair | monster | advanced | cleric treasure |
|
||||
| 5 | `vampirebordello` | Vampire Bordello | monster | advanced | cleric treasure |
|
||||
| 6 | `goblinarmory` | Goblin Armory | monster | | fighter treasure |
|
||||
| 7 | `golemfactory` | Golem Factory | monster | | fighter treasure |
|
||||
| 8 | `minotaursmaze` | Minotaur's Maze | monster | | fighter treasure |
|
||||
| 9 | `neanderthalcave` | Neanderthal Cave | monster | | fighter treasure |
|
||||
| 10 | `beastmenagerie` | Beast Menagerie | monster | advanced | fighter treasure |
|
||||
| 11 | `monstersballroom` | Monster's Ballroom | monster | advanced | fighter treasure |
|
||||
| 12 | `brainsuckerhive` | Brainsucker Hive | monster | | mage treasure |
|
||||
| 13 | `darklaboratory` | Dark Laboratory | trap | | mage treasure |
|
||||
| 14 | `hauntedlibrary` | Haunted Library | trap | | mage treasure |
|
||||
| 15 | `witchskitchen` | Witch's Kitchen | monster | | mage treasure |
|
||||
| 16 | `allseeingeye` | All-Seeing Eye | trap | advanced | mage treasure |
|
||||
| 17 | `ligersden` | Liger's Den | monster | advanced | mage treasure |
|
||||
| 18 | `bottomlesspit` | Bottomless Pit | trap | | thief treasure |
|
||||
| 19 | `boulderramp` | Boulder Ramp | trap | | thief treasure |
|
||||
| 20 | `dizzygashallway` | Dizzygas Hallway | trap | | thief treasure |
|
||||
| 21 | `jackpotstash` | Jackpot Stash | trap | | thief treasure |
|
||||
| 22 | `recyclingcenter` | Recycling Center | trap | advanced | thief treasure |
|
||||
| 23 | `thecrushinator` | The Crushinator | trap | advanced | thief treasure |
|
||||
| 24 | `centipedetunnel` | Centipede Tunnel | monster | | fighter+mage treasure |
|
||||
| 25 | `constructionzone` | Construction Zone | trap | | fighter+thief treasure |
|
||||
| 26 | `dragonhatchery` | Dragon Hatchery | monster | | all-class treasure |
|
||||
| 27 | `mimicvault` | Mimic Vault | trap | | mage+thief treasure |
|
||||
| 28 | `monstrousmonument` | Monstrous Monument | trap | | cleric+fighter treasure |
|
||||
| 29 | `torturechamber` | Torture Chamber | trap | | cleric+thief treasure |
|
||||
| 30 | `zombieprison` | Zombie Prison | monster | | cleric+mage treasure |
|
||||
|
||||
> Priority: rooms 0–17 (Cleric/Fighter/Mage wings) show up most often; the
|
||||
> multi-class rooms (24–30) and advanced rooms (4, 5, 10, 11, 16, 17, 22, 23)
|
||||
> are lower frequency but higher visual payoff (bigger/scarier).
|
||||
|
||||
---
|
||||
|
||||
## 2. Spell sheet — `dungeonboss-spells.png`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/dungeonboss-spells.png` |
|
||||
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the spell art window) |
|
||||
| **Sheet size** | **4 columns × 4 rows = 880 × 1200 px** holds all 16 frames exactly |
|
||||
| **Status** | ⛔ Procedural until you create it (then set `spellSheet.path` in the JSON) |
|
||||
| **JSON** | `spellSheet` (set `path`) + `spells` map (already filled in for all 16 ids) |
|
||||
|
||||
Spells are shown in-hand at small size and up to ~280×236 in the play/reveal
|
||||
zoom. These are one-shot magical effects — go for a punchy central icon/scene
|
||||
(a rune, a burst of energy, a monster silhouette) rather than a busy tableau.
|
||||
|
||||
### Frame map
|
||||
|
||||
| Frame | id | Name | Effect flavor |
|
||||
|---:|---|---|---|
|
||||
| 0 | `annihilator` | Annihilator | destructive burst |
|
||||
| 1 | `assassin` | Assassin | targeted kill/damage |
|
||||
| 2 | `cavein` | Cave In | collapsing rock/trap |
|
||||
| 3 | `counterspell` | Counterspell | magical negation |
|
||||
| 4 | `exhaustion` | Exhaustion | draining/weakening |
|
||||
| 5 | `fear` | Fear | terror/scare effect |
|
||||
| 6 | `freeze` | Freeze | ice/stasis |
|
||||
| 7 | `giantsize` | Giant Size | growth/enlarge |
|
||||
| 8 | `jeopardy` | Jeopardy | risk/gambling theme |
|
||||
| 9 | `koboldstrike` | Kobold Strike | small creature ambush |
|
||||
| 10 | `motivation` | Motivation | rallying/buff |
|
||||
| 11 | `princessinperil` | Princess in Peril | damsel/rescue-bait theme |
|
||||
| 12 | `soulharvest` | Soul Harvest | dark reaping magic |
|
||||
| 13 | `teleportation` | Teleportation | portal/warp |
|
||||
| 14 | `trepidation` | Trepidation | dread/hesitation |
|
||||
| 15 | `zombieattack` | Zombie Attack | undead horde |
|
||||
|
||||
---
|
||||
|
||||
## 3. Hero sheet — `dungeonboss-heroes.png`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/dungeonboss-heroes.png` |
|
||||
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the hero art window) |
|
||||
| **Sheet size** | **7 columns × 6 rows = 1540 × 1800 px** holds all 41 frames (1 spare) |
|
||||
| **Status** | ⛔ Procedural until you create it (then set `heroSheet.path` in the JSON) |
|
||||
| **JSON** | `heroSheet` (set `path`) + `heroes` map (already filled in for all 41 ids) |
|
||||
|
||||
Heroes are the townsfolk marching toward the dungeons — shown small on the
|
||||
town track (~106×148) and up to ~280×390 in the inspect view. Class color
|
||||
coding: **Fighter** red, **Mage** blue, **Thief** gold, **Cleric** teal (see
|
||||
`CLASS_INFO` in `DungeonBossData.js` for exact hex). **Epic** heroes (higher
|
||||
HP, named/unique-flavor) should read as visually grander than ordinary ones.
|
||||
|
||||
### Frame map
|
||||
|
||||
Class legend: **F** = Fighter, **M** = Mage, **T** = Thief, **C** = Cleric.
|
||||
|
||||
| Frame | id | Name | Class | HP | Epic |
|
||||
|---:|---|---|---|---:|---|
|
||||
| 0 | `nick` | Nick the Masher | C | 4 | |
|
||||
| 1 | `pugi` | Pugi the Druidess | C | 4 | |
|
||||
| 2 | `acacia` | Acacia, Warrior of Light | C | 6 | |
|
||||
| 3 | `charles` | Charles the Young | C | 6 | |
|
||||
| 4 | `delatorious` | Delatorious | C | 8 | |
|
||||
| 5 | `romero` | Romero, the Indigo Friar | C | 8 | |
|
||||
| 6 | `boden` | Boden the Pantless | F | 4 | |
|
||||
| 7 | `jarek` | Jarek the Squire | F | 4 | |
|
||||
| 8 | `firesbreath` | Fire's Breath | F | 6 | |
|
||||
| 9 | `samuraitom` | Samurai Tom | F | 6 | |
|
||||
| 10 | `johnny` | Johnny of the Evening Watch | F | 8 | |
|
||||
| 11 | `crystolalan` | Crystol & A'lan of Gerd | F | 8 | |
|
||||
| 12 | `tieg` | Tieg and the Magic Bubble | M | 4 | |
|
||||
| 13 | `brandork` | Brandork the Neverwrong | M | 4 | |
|
||||
| 14 | `mitchell` | Mitchell, the Judge | M | 6 | |
|
||||
| 15 | `kalish` | Kalish Ninefingers | M | 6 | |
|
||||
| 16 | `dartteon` | Dartteon, Elf Pyromancer | M | 8 | |
|
||||
| 17 | `koey` | Koey, the Last Dragon Mage | M | 8 | |
|
||||
| 18 | `joman` | Joman Chimm, Cutpurse | T | 4 | |
|
||||
| 19 | `lance` | Lance Uppercut | T | 4 | |
|
||||
| 20 | `jesta` | Jesta the Rogue | T | 6 | |
|
||||
| 21 | `kins` | Kins Klauski | T | 6 | |
|
||||
| 22 | `digby` | Sir Digby Apple | T | 8 | |
|
||||
| 23 | `jerome` | Jerome, Kung Fu Monkey | T | 8 | |
|
||||
| 24 | `fool` | The Fool | — | 2 | jester, no class colors |
|
||||
| 25 | `katelyn` | Katelyn, Angelic Healer | C | 11 | ✅ |
|
||||
| 26 | `kerberos` | Kerberos Dirtbeard | C | 11 | ✅ |
|
||||
| 27 | `vanette` | Lord Van Ette | C | 13 | ✅ |
|
||||
| 28 | `jejune` | Jejune & Everlea | C | 13 | ✅ |
|
||||
| 29 | `frankov` | Frankov, the Envoy | F | 11 | ✅ |
|
||||
| 30 | `nate` | Nate the Squidslayer | F | 11 | ✅ |
|
||||
| 31 | `antonius` | Antonius, the Rune Knight | F | 13 | ✅ |
|
||||
| 32 | `asmor` | Asmor the Aweless | F | 13 | ✅ |
|
||||
| 33 | `tempros` | Tempros the Time Marauder | M | 11 | ✅ |
|
||||
| 34 | `chiakang` | Chia Kang, Warlock of Yu | M | 11 | ✅ |
|
||||
| 35 | `terric` | Terric Warhelm | M | 13 | ✅ |
|
||||
| 36 | `wayward` | Wayward, the Drifter | M | 13 | ✅ |
|
||||
| 37 | `hya` | Hya, Legendary Shinobi | T | 11 | ✅ |
|
||||
| 38 | `blackbeard` | Blackbeard Jake | T | 11 | ✅ |
|
||||
| 39 | `cecil` | Cecil Leoran, Factotum | T | 13 | ✅ |
|
||||
| 40 | `wallbanger` | Wallbanger Basketweaver | T | 13 | ✅ |
|
||||
|
||||
> Priority: the 24 ordinary heroes (0–23) plus the Fool (24) are seen every
|
||||
> game and every player count — paint those first. The 16 epics (25–40) are
|
||||
> lower-frequency but should look noticeably more impressive/detailed.
|
||||
|
||||
---
|
||||
|
||||
## 4. Boss sheet — `dungeonboss-bosses.png`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/dungeonboss-bosses.png` |
|
||||
| **Frame size** | **260 × 360 px** (aspect ≈ 0.722 : 1, large portrait — matches the boss art window) |
|
||||
| **Sheet size** | **4 columns × 2 rows = 1040 × 720 px** holds all 8 frames exactly |
|
||||
| **Status** | ⛔ Procedural until you create it (then set `bossSheet.path` in the JSON) |
|
||||
| **JSON** | `bossSheet` (set `path`) + `bosses` map (already filled in for all 8 ids) |
|
||||
|
||||
Each player picks one boss at the start of the game — shown persistently on
|
||||
their dungeon (~88×124 to ~168×236) and full-size (~300×430) in the reveal/win
|
||||
screen. These are the marquee art assets of the game: eight distinct, iconic
|
||||
dungeon-master portraits. Higher XP bosses go first in turn order, so no
|
||||
in-art ranking is implied — just make each one visually distinct and readable
|
||||
at the small on-board size.
|
||||
|
||||
### Frame map
|
||||
|
||||
| Frame | id | Name | XP | Treasure class | Level-up flavor |
|
||||
|---:|---|---|---:|---|---|
|
||||
| 0 | `draculord` | Draculord | 900 | cleric | vampire lord, steals a card |
|
||||
| 1 | `cleopatra` | Cleopatra | 850 | thief | Egyptian queen, fetches advanced trap |
|
||||
| 2 | `kingcroak` | King Croak | 800 | fighter | frog king, fetches advanced monster |
|
||||
| 3 | `xyzax` | Xyzax | 750 | cleric | alien/otherworldly, recovers discards |
|
||||
| 4 | `cerebellus` | Cerebellus | 650 | mage | brain/psychic horror, draws spells |
|
||||
| 5 | `seducia` | Seducia | 600 | mage | succubus/temptress, lures a hero |
|
||||
| 6 | `gorgona` | Gorgona | 500 | thief | gorgon/medusa, turns a hero to stone |
|
||||
| 7 | `robobo` | Robobo | 400 | fighter | robot, wrecks opponents' rooms |
|
||||
|
||||
---
|
||||
|
||||
## 5. Menu icon — `game-icons.png` frame **77** *(shared sheet)*
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `public/assets/images/game-icons.png` (existing shared sheet) |
|
||||
| **Sheet size** | 660 × 660 px |
|
||||
| **Frame size** | **44 × 44 px** |
|
||||
| **Layout** | 15 columns × 15 rows |
|
||||
| **Dungeon Boss frame** | **77** → row 5, col 2 → pixel box **x 88–132, y 220–264** |
|
||||
|
||||
Add a 44×44 icon for Dungeon Boss into cell 77 of the existing sheet (the menu
|
||||
tile pulls `game-icons` frame `77`). Keep it readable at tiny size — e.g. a
|
||||
grinning dungeon-entrance skull, crossed sword/wand, or a small horned boss
|
||||
silhouette. This is the only sprite that goes into an **existing** sheet
|
||||
rather than a new file.
|
||||
|
||||
---
|
||||
|
||||
## Quick checklist
|
||||
|
||||
- [ ] `dungeonboss-rooms.png` — 1800×1200 (6×6 grid), 300×200 per frame, 31 frames (0–30); then set `roomSheet.path` in `dungeonboss-artwork.json`.
|
||||
- [ ] `dungeonboss-spells.png` — 880×1200 (4×4 grid), 220×300 per frame, 16 frames (0–15); then set `spellSheet.path` in `dungeonboss-artwork.json`.
|
||||
- [ ] `dungeonboss-heroes.png` — 1540×1800 (7×6 grid), 220×300 per frame, 41 frames (0–40); then set `heroSheet.path` in `dungeonboss-artwork.json`.
|
||||
- Priority 1: ordinary heroes + Fool (0–24)
|
||||
- Priority 2: epic heroes (25–40)
|
||||
- [ ] `dungeonboss-bosses.png` — 1040×720 (4×2 grid), 260×360 per frame, 8 frames (0–7); then set `bossSheet.path` in `dungeonboss-artwork.json`.
|
||||
- [ ] `game-icons.png` frame 77 — 44×44 menu icon.
|
||||
|
||||
The frame→id mappings for all four sheets are already filled in in
|
||||
`public/data/dungeonboss-artwork.json` — once you drop the PNGs in and set the
|
||||
four `path` fields, art appears with no code changes.
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
// SWDBGAI.js — synchronous heuristics over the engine's public view (opponent
|
||||
// hand/decks are stubs; the AI never sees hidden information). One handler per
|
||||
// decision kind, routed through decide(). Skill 1–5 follows the house shape:
|
||||
// widening top-N with score noise and a blunder chance at low skill.
|
||||
|
||||
import { cardDef, baseDef, getData } from './SWDBGData.js';
|
||||
import { legalActions, entryAttack, forceWith, purchaseCost } from './SWDBGLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { topN: 4, blunder: 0.35, noise: 3.0, actChance: 0.35, bountyChance: 0.25, delay: [650, 1100] },
|
||||
2: { topN: 3, blunder: 0.20, noise: 2.0, actChance: 0.55, bountyChance: 0.50, delay: [600, 1000] },
|
||||
3: { topN: 3, blunder: 0.08, noise: 1.2, actChance: 0.80, bountyChance: 0.75, delay: [550, 950] },
|
||||
4: { topN: 2, blunder: 0.02, noise: 0.6, actChance: 0.95, bountyChance: 0.90, delay: [480, 860] },
|
||||
5: { topN: 1, blunder: 0.00, noise: 0.0, actChance: 1.00, bountyChance: 1.00, delay: [420, 800] },
|
||||
};
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
function pickScored(cands, skill, rnd) {
|
||||
if (!cands.length) return null;
|
||||
const prof = profileFor(skill);
|
||||
if (rnd() < prof.blunder) return cands[Math.floor(rnd() * cands.length)].choice;
|
||||
const jittered = cands.map((c) => ({ choice: c.choice, s: c.s + (rnd() * 2 - 1) * prof.noise }));
|
||||
jittered.sort((a, b) => b.s - a.s);
|
||||
return jittered[Math.floor(rnd() * Math.min(prof.topN, jittered.length))].choice;
|
||||
}
|
||||
|
||||
const me = (view, seat) => view.players[seat];
|
||||
const foe = (view, seat) => view.players[1 - seat];
|
||||
|
||||
// Deck-building quality of a card — used for buys, discards, and exiles.
|
||||
export function cardValue(def) {
|
||||
let v = (def.attack || 0) + (def.resources || 0) + (def.force || 0) * 0.7;
|
||||
if (def.ability) v += 1.2;
|
||||
if (def.type === 'capital') v += (def.hp || 0) * 0.25 + (def.resources || 0) * 1.3;
|
||||
if (def.unique) v += 0.5;
|
||||
return v;
|
||||
}
|
||||
|
||||
function worstHandCard(view, seat, { exclude = null } = {}) {
|
||||
const hand = me(view, seat).hand.filter((c) => c.uid !== exclude);
|
||||
if (!hand.length) return null;
|
||||
return hand.slice().sort((a, b) => cardValue(cardDef(a)) - cardValue(cardDef(b)))[0];
|
||||
}
|
||||
|
||||
// ── turn ─────────────────────────────────────────────────────────────────────
|
||||
function chooseTurn(view, seat, skill, rnd) {
|
||||
const prof = profileFor(skill);
|
||||
const p = me(view, seat);
|
||||
const o = foe(view, seat);
|
||||
const legal = legalActions(view, seat);
|
||||
|
||||
// 1. Play out the hand. Hold one cheap card back as Jabba/exile-cost fodder
|
||||
// when an unused exileFromHand ability is on the table.
|
||||
if (legal.plays.length) {
|
||||
const needsFodder = [...p.inPlay, ...p.capitals].some((e) => {
|
||||
const ab = cardDef(e.card).ability;
|
||||
return ab?.timing === 'action' && ab.cost?.exileFromHand && !e.used;
|
||||
});
|
||||
let playable = p.hand;
|
||||
if (needsFodder && p.hand.length === 1) playable = [];
|
||||
if (playable.length) {
|
||||
// Worst first, so kept fodder is the cheapest card.
|
||||
const pick = playable.slice().sort((a, b) => cardValue(cardDef(b)) - cardValue(cardDef(a)))[0];
|
||||
return { type: 'play', uid: pick.uid };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Abilities (skill-gated).
|
||||
if (legal.abilities.length && rnd() < prof.actChance) {
|
||||
const a = legal.abilities[0];
|
||||
const action = { type: 'ability', zone: a.zone, uid: a.uid };
|
||||
// Supply the exile cost card when needed.
|
||||
const def = a.zone === 'base' ? baseDef(p.faction, p.base.id) : cardDef(findInPlay(p, a.uid).card);
|
||||
if (def.ability?.cost?.exileFromHand) {
|
||||
const fodder = worstHandCard(view, seat);
|
||||
if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal);
|
||||
action.costUid = fodder.uid;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
return nextAfterAbilities(view, seat, skill, rnd, legal);
|
||||
}
|
||||
|
||||
function findInPlay(p, uid) {
|
||||
return p.inPlay.find((e) => e.card.uid === uid) || p.capitals.find((e) => e.card.uid === uid);
|
||||
}
|
||||
|
||||
function nextAfterAbilities(view, seat, skill, rnd, legal) {
|
||||
const prof = profileFor(skill);
|
||||
const p = me(view, seat);
|
||||
const o = foe(view, seat);
|
||||
|
||||
// 3. Bounty hunting / sabotage — spend the smallest sufficient attacker set
|
||||
// when the reward + denial beats the base damage forfeited.
|
||||
if (legal.rowTargets.length && rnd() < prof.bountyChance) {
|
||||
const options = [];
|
||||
for (const t of legal.rowTargets) {
|
||||
const target = view.galaxy.row.find((c) => c.uid === t.uid);
|
||||
const tdef = cardDef(target);
|
||||
const picked = pickBountySquad(view, seat, tdef.target);
|
||||
if (!picked) continue;
|
||||
const r = tdef.reward || {};
|
||||
const rewardV = (r.resources || 0) + (r.force || 0) * 1.2 + (r.draw || 0) * 1.8;
|
||||
const denialV = cardValue(tdef) * 0.45; // enemy can no longer buy it
|
||||
const spent = picked.total * 0.7; // base damage forfeited
|
||||
const s = rewardV + denialV - spent;
|
||||
if (s > -0.5) options.push({ choice: { type: 'attackRow', targetUid: t.uid, uids: picked.uids }, s });
|
||||
}
|
||||
const pick = pickScored(options, skill, rnd);
|
||||
if (pick) return pick;
|
||||
}
|
||||
|
||||
// 4. Buys, best value per resource.
|
||||
if (legal.buys.length) {
|
||||
const options = [{ choice: null, s: 0.4 }]; // skipping junk is allowed
|
||||
for (const b of legal.buys) {
|
||||
const def = b.uid === 'outerrim' ? getData().json.outerRimPilot
|
||||
: cardDef(view.galaxy.row.find((c) => c.uid === b.uid));
|
||||
let s = cardValue(def) - b.cost * 0.45;
|
||||
if (def.faction === p.faction) s += 0.5; // faction synergy & denial-proofing
|
||||
if (def.type === 'capital' && o.lostBases >= 2) s -= 1; // late capitals rarely pay off
|
||||
if (view.turnCount <= 6 && def.cost <= 2 && def.ability) s += 0.4; // early thinning/tempo
|
||||
options.push({ choice: { type: 'buy', uid: b.uid }, s });
|
||||
}
|
||||
const pick = pickScored(options, skill, rnd);
|
||||
if (pick) return pick;
|
||||
}
|
||||
|
||||
// 5. Attack the base with everything still uncommitted.
|
||||
if (legal.baseAttackers.length) {
|
||||
const total = legal.baseAttackers.reduce((a, uid) => a + entryAttack(view, seat, findInPlay(p, uid)), 0);
|
||||
if (total > 0) {
|
||||
const order = o.capitals.slice()
|
||||
.sort((a, b) => (cardDef(a.card).hp - a.damage) - (cardDef(b.card).hp - b.damage))
|
||||
.map((e) => e.card.uid);
|
||||
return { type: 'attackBase', uids: legal.baseAttackers, capitalOrder: order };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'endTurn' };
|
||||
}
|
||||
|
||||
// Smallest set of free units whose row attack meets the target.
|
||||
function pickBountySquad(view, seat, target) {
|
||||
const p = me(view, seat);
|
||||
const free = p.inPlay.filter((e) => !e.committed)
|
||||
.map((e) => ({ uid: e.card.uid, atk: entryAttack(view, seat, e, { vsRow: true }) }))
|
||||
.filter((e) => e.atk > 0)
|
||||
.sort((a, b) => a.atk - b.atk);
|
||||
const total = free.reduce((a, e) => a + e.atk, 0);
|
||||
if (total < target) return null;
|
||||
// Greedy from the top down, then trim from the bottom.
|
||||
const picked = [];
|
||||
let sum = 0;
|
||||
for (let i = free.length - 1; i >= 0 && sum < target; i--) { picked.push(free[i]); sum += free[i].atk; }
|
||||
for (let i = picked.length - 1; i >= 0; i--) {
|
||||
if (sum - picked[i].atk >= target) { sum -= picked[i].atk; picked.splice(i, 1); }
|
||||
}
|
||||
return { uids: picked.map((e) => e.uid), total: sum };
|
||||
}
|
||||
|
||||
// ── other decisions ──────────────────────────────────────────────────────────
|
||||
function chooseBase(view, seat, skill, rnd, decision) {
|
||||
const p = me(view, seat);
|
||||
const options = decision.options.map((id) => {
|
||||
const b = baseDef(p.faction, id);
|
||||
let s = b.hp * 0.5;
|
||||
const ab = b.ability;
|
||||
if (ab) {
|
||||
if (ab.op === 'draw') s += 2.5;
|
||||
if (ab.op === 'gainResources') s += 2;
|
||||
if (ab.op === 'capitalDiscount') s += view.turnCount <= 8 ? 2 : 0.5;
|
||||
if (ab.op === 'onPlayTraitAttack') s += 1.5;
|
||||
if (ab.op === 'damageBase') s += 2.5;
|
||||
if (ab.op === 'exileCards') s += view.turnCount <= 8 ? 1.5 : 0.3;
|
||||
}
|
||||
// Behind on bases? Prefer raw hit points.
|
||||
if (p.lostBases > foe(view, seat).lostBases) s += b.hp * 0.25;
|
||||
return { choice: id, s };
|
||||
});
|
||||
return pickScored(options, skill, rnd);
|
||||
}
|
||||
|
||||
function chooseTarget(view, seat, skill, rnd, decision) {
|
||||
const p = me(view, seat);
|
||||
const cands = decision.candidates || [];
|
||||
const scored = [];
|
||||
const skipOk = decision.optional;
|
||||
switch (decision.op) {
|
||||
case 'discardRow':
|
||||
for (const c of cands) {
|
||||
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
|
||||
const enemy = def.faction !== 'neutral' && def.faction !== p.faction;
|
||||
scored.push({ choice: c, s: enemy ? cardValue(def) : cardValue(def) * 0.2 - 1 });
|
||||
}
|
||||
if (skipOk) scored.push({ choice: null, s: 1.5 });
|
||||
break;
|
||||
case 'destroyCapital':
|
||||
for (const c of cands) {
|
||||
if (c.kind === 'capital') {
|
||||
const e = foe(view, seat).capitals.find((x) => x.card.uid === c.uid);
|
||||
scored.push({ choice: c, s: e ? cardValue(cardDef(e.card)) + 2 : 0 });
|
||||
} else {
|
||||
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
|
||||
scored.push({ choice: c, s: def.faction !== p.faction && def.faction !== 'neutral' ? cardValue(def) : 0.5 });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'exileCards':
|
||||
for (const c of cands) {
|
||||
const zone = c.zone === 'hand' ? p.hand : p.discard;
|
||||
const def = cardDef(zone.find((x) => x.uid === c.uid));
|
||||
// Exile starters and junk; never exile good cards.
|
||||
scored.push({ choice: c, s: 2.2 - cardValue(def) });
|
||||
}
|
||||
if (skipOk) scored.push({ choice: null, s: 0 });
|
||||
break;
|
||||
case 'recoverDiscard':
|
||||
for (const c of cands) {
|
||||
const def = cardDef(p.discard.find((x) => x.uid === c.uid));
|
||||
scored.push({ choice: c, s: cardValue(def) });
|
||||
}
|
||||
break;
|
||||
case 'freePurchase':
|
||||
for (const c of cands) {
|
||||
const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid));
|
||||
scored.push({ choice: c, s: cardValue(def) });
|
||||
}
|
||||
break;
|
||||
case 'lookTop': case 'revealTopDiscard': {
|
||||
const def = decision.peekId ? cardDef(decision.peekId) : null;
|
||||
const enemy = def && def.faction !== 'neutral' && def.faction !== p.faction;
|
||||
const junk = def && cardValue(def) < 2;
|
||||
const discard = enemy || junk;
|
||||
return cands.find((c) => c.v === (discard ? 'discard' : 'keep')) || cands[0];
|
||||
}
|
||||
default:
|
||||
return decision.optional ? null : cands[0] || null;
|
||||
}
|
||||
const pick = pickScored(scored, skill, rnd);
|
||||
return pick === undefined ? null : pick;
|
||||
}
|
||||
|
||||
function chooseOppDiscard(view, seat) {
|
||||
const worst = worstHandCard(view, seat);
|
||||
return worst ? worst.uid : null;
|
||||
}
|
||||
|
||||
function chooseSpy(view, seat, decision) {
|
||||
if (!decision.options.includes('discard')) return 'force';
|
||||
if (!decision.options.includes('force')) return 'discard';
|
||||
const worst = worstHandCard(view, seat);
|
||||
return worst && cardValue(cardDef(worst)) <= 1.2 ? 'discard' : 'force';
|
||||
}
|
||||
|
||||
function chooseOption(view, seat, skill, rnd, decision) {
|
||||
const o = foe(view, seat);
|
||||
const scored = decision.options.map((ab, i) => {
|
||||
let s = 1;
|
||||
if (ab.op === 'draw') s = (ab.n || 1) * 1.4;
|
||||
if (ab.op === 'oppDiscard') s = o.hand.length ? 1.6 : 0;
|
||||
if (ab.op === 'gainResources') s = (ab.n || 1) * 1.0;
|
||||
return { choice: i, s };
|
||||
});
|
||||
return pickScored(scored, skill, rnd) ?? 0;
|
||||
}
|
||||
|
||||
// ── Router ──────────────────────────────────────────────────────────────────
|
||||
// Returns the argument for the matching act* mutator:
|
||||
// turn → action object · chooseBase → baseId · target → ref|null ·
|
||||
// oppDiscard → uid · spyChoice → 'discard'|'force' · chooseOption → idx
|
||||
export function decide(view, decision, skill, rnd = Math.random) {
|
||||
const seat = decision.seat;
|
||||
switch (decision.kind) {
|
||||
case 'turn': return chooseTurn(view, seat, skill, rnd);
|
||||
case 'chooseBase': return chooseBase(view, seat, skill, rnd, decision);
|
||||
case 'target': return chooseTarget(view, seat, skill, rnd, decision);
|
||||
case 'oppDiscard': return chooseOppDiscard(view, seat);
|
||||
case 'spyChoice': return chooseSpy(view, seat, decision);
|
||||
case 'chooseOption': return chooseOption(view, seat, skill, rnd, decision);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# Star Wars Deckbuilder — Sprite / Art Spec
|
||||
|
||||
Everything in SWDBG renders procedurally out of the box (faction-colored frame,
|
||||
cost badge, stats, name plate, rules text), so the game is fully playable with
|
||||
**no** art. This document lists the optional sprite sheets you can drop in to
|
||||
replace the placeholder art windows, with exact dimensions, layout, and
|
||||
frame-by-frame maps.
|
||||
|
||||
All art is wired through **`public/data/swdbg-artwork.json`**. The scene checks
|
||||
whether each sheet's texture is loaded; if it is, it uses your frame, otherwise
|
||||
it falls back to procedural drawing. You never need to touch code — just add a
|
||||
PNG, set its `path` in that JSON (it's currently empty for both sheets), and
|
||||
reload.
|
||||
|
||||
Frame numbering is **row-major, 0-based**: frame 0 is top-left, count
|
||||
left-to-right then down to the next row.
|
||||
|
||||
Only paint the illustration — the card frame, border, faction edge color,
|
||||
unique-card gold inset, cost/stat badges, name plate, and rules text are all
|
||||
drawn procedurally on top of your art. Art is scaled to **fit** the art window
|
||||
(no cropping), so match the aspect ratio below for edge-to-edge fill; other
|
||||
aspects letterbox inside the window.
|
||||
|
||||
---
|
||||
|
||||
## 1. Card art sheet — `swdbg-cards-art.png`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/swdbg-cards-art.png` |
|
||||
| **Frame size** | **300 × 230 px** (aspect ≈ 1.30 : 1, matches the card art window) |
|
||||
| **Sheet size** | **8 columns × 7 rows = 2400 × 1610 px** holds all 55 frames (1 spare) |
|
||||
| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) |
|
||||
| **JSON** | `cardSheet` (set `path`) + `cards` map (already filled in for all 55 ids) |
|
||||
|
||||
The art window sits just below the name plate on every unit/ship/character
|
||||
card. It's shown at roughly 100×77 px on a resting hand card and up to
|
||||
~280×215 px in the inspect/reveal zoom, so design for readability at the
|
||||
smaller size — bold silhouettes and clear color-blocking over fine detail.
|
||||
|
||||
### Frame map
|
||||
|
||||
Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim.
|
||||
|
||||
| Frame | id | Name | Faction | Notes |
|
||||
|---:|---|---|---|---|
|
||||
| 0 | `allianceshuttle` | Alliance Shuttle | R | |
|
||||
| 1 | `rebeltrooper` | Rebel Trooper | R | |
|
||||
| 2 | `templeguardian` | Temple Guardian | R | |
|
||||
| 3 | `imperialshuttle` | Imperial Shuttle | E | |
|
||||
| 4 | `stormtrooper` | Stormtrooper | E | |
|
||||
| 5 | `inquisitor` | Inquisitor | E | |
|
||||
| 6 | `outerrimpilot` | Outer Rim Pilot | N | shared neutral card |
|
||||
| 7 | `tiefighter` | TIE Fighter | E | |
|
||||
| 8 | `scouttrooper` | Scout Trooper | E | |
|
||||
| 9 | `deathtrooper` | Death Trooper | E | |
|
||||
| 10 | `tiebomber` | TIE Bomber | E | |
|
||||
| 11 | `tieinterceptor` | TIE Interceptor | E | |
|
||||
| 12 | `atst` | AT-ST | E | |
|
||||
| 13 | `atat` | AT-AT | E | |
|
||||
| 14 | `generalveers` | General Veers | E | unique |
|
||||
| 15 | `grandmofftarkin` | Grand Moff Tarkin | E | unique |
|
||||
| 16 | `directorkrennic` | Director Krennic | E | unique |
|
||||
| 17 | `darthvader` | Darth Vader | E | unique |
|
||||
| 18 | `emperorpalpatine` | Emperor Palpatine | E | unique |
|
||||
| 19 | `gozanticruiser` | Gozanti Cruiser | E | |
|
||||
| 20 | `imperialcarrier` | Imperial Carrier | E | |
|
||||
| 21 | `stardestroyer` | Star Destroyer | E | |
|
||||
| 22 | `rebelpathfinder` | Rebel Pathfinder | R | |
|
||||
| 23 | `ywing` | Y-Wing | R | |
|
||||
| 24 | `snowspeeder` | Snowspeeder | R | |
|
||||
| 25 | `durosspy` | Duros Spy | R | |
|
||||
| 26 | `xwing` | X-Wing | R | |
|
||||
| 27 | `rebelcommando` | Rebel Commando | R | |
|
||||
| 28 | `cassianandor` | Cassian Andor | R | unique |
|
||||
| 29 | `k2so` | K-2SO | R | unique |
|
||||
| 30 | `chirrutimwe` | Chirrut Îmwe | R | unique |
|
||||
| 31 | `jynerso` | Jyn Erso | R | unique |
|
||||
| 32 | `chewbacca` | Chewbacca | R | unique |
|
||||
| 33 | `hansolo` | Han Solo | R | unique |
|
||||
| 34 | `princessleia` | Princess Leia | R | unique |
|
||||
| 35 | `lukeskywalker` | Luke Skywalker | R | unique |
|
||||
| 36 | `blockaderunner` | Blockade Runner | R | |
|
||||
| 37 | `hammerheadcorvette` | Hammerhead Corvette | R | |
|
||||
| 38 | `medicalfrigate` | Medical Frigate | R | |
|
||||
| 39 | `moncalamaricruiser` | Mon Calamari Cruiser | R | |
|
||||
| 40 | `millenniumfalcon` | Millennium Falcon | R | unique |
|
||||
| 41 | `z95headhunter` | Z-95 Headhunter | N | |
|
||||
| 42 | `jawascavenger` | Jawa Scavenger | N | |
|
||||
| 43 | `keldormystic` | Kel Dor Mystic | N | |
|
||||
| 44 | `rodiangunslinger` | Rodian Gunslinger | N | |
|
||||
| 45 | `hwk290` | HWK-290 | N | |
|
||||
| 46 | `twileksmuggler` | Twi'lek Smuggler | N | |
|
||||
| 47 | `fangfighter` | Fang Fighter | N | |
|
||||
| 48 | `quarrenmercenary` | Quarren Mercenary | N | |
|
||||
| 49 | `bossk` | Bossk | N | unique bounty hunter |
|
||||
| 50 | `ig88` | IG-88 | N | unique bounty hunter |
|
||||
| 51 | `bobafett` | Boba Fett | N | unique bounty hunter |
|
||||
| 52 | `jabbathehutt` | Jabba the Hutt | N | unique |
|
||||
| 53 | `crocruiser` | Crolute Cruiser | N | |
|
||||
| 54 | `khetanna` | Khetanna (Jabba's Sail Barge) | N | unique |
|
||||
|
||||
> Priority: uniques (14–18, 28–35, 40, 49–52, 54) and starters/commons that
|
||||
> show up every game (0–5, 7–9, 22–24, 26–27) read best painted first.
|
||||
|
||||
---
|
||||
|
||||
## 2. Base art sheet — `swdbg-bases-art.png`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Path** | `public/assets/images/swdbg-bases-art.png` |
|
||||
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the base art window) |
|
||||
| **Sheet size** | **5 columns × 2 rows = 1100 × 600 px** holds all 10 frames |
|
||||
| **Status** | ⛔ Procedural until you create it (then set `baseSheet.path` in the JSON) |
|
||||
| **JSON** | `baseSheet` (set `path`) + `bases` map (already filled in for all 10 ids) |
|
||||
|
||||
Bases are the large planet/location cards planted on each player's side of the
|
||||
board (shown at ~300×190 landscape during play — note the base *card* itself
|
||||
is wider-than-tall on screen, but the art window inside it is a portrait
|
||||
crop, so compose art with a strong vertical focal point that still reads when
|
||||
letterboxed). Also shown up to 560×340 in the win/lose reveal.
|
||||
|
||||
### Frame map
|
||||
|
||||
| Frame | id | Name | Notes |
|
||||
|---:|---|---|---|
|
||||
| 0 | `dantooine` | Dantooine | Rebel base, grassy plains |
|
||||
| 1 | `hoth` | Hoth | Rebel base, ice world |
|
||||
| 2 | `sullust` | Sullust | Rebel base, volcanic |
|
||||
| 3 | `moncala` | Mon Cala | Rebel base, ocean world |
|
||||
| 4 | `yavin4` | Yavin 4 | Rebel base, jungle moon/temple |
|
||||
| 5 | `lothal` | Lothal | Imperial base, plains/wheat |
|
||||
| 6 | `corellia` | Corellia | Imperial base, shipyards |
|
||||
| 7 | `coruscant` | Coruscant | Imperial base, cityscape |
|
||||
| 8 | `deathstar` | Death Star | Imperial base, superweapon |
|
||||
| 9 | `endor` | Endor | Imperial base, forest moon |
|
||||
|
||||
---
|
||||
|
||||
## 3. Menu icon — `game-icons.png` frame **78** *(shared sheet)*
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **File** | `public/assets/images/game-icons.png` (existing shared sheet) |
|
||||
| **Sheet size** | 660 × 660 px |
|
||||
| **Frame size** | **44 × 44 px** |
|
||||
| **Layout** | 15 columns × 15 rows |
|
||||
| **SWDBG frame** | **78** → row 5, col 3 → pixel box **x 132–176, y 220–264** |
|
||||
|
||||
Add a 44×44 icon for the Star Wars Deckbuilder into cell 78 of the existing
|
||||
sheet (the menu tile pulls `game-icons` frame `78`). Keep it readable at tiny
|
||||
size — e.g. a crossed rebel/imperial insignia, a small TIE fighter or X-Wing
|
||||
silhouette, or the Force-track balance symbol. This is the only sprite that
|
||||
goes into an **existing** sheet rather than a new file.
|
||||
|
||||
---
|
||||
|
||||
## Quick checklist
|
||||
|
||||
- [ ] `swdbg-cards-art.png` — 2400×1610 (8×7 grid), 300×230 per frame, 55 frames (0–54); then set `cardSheet.path` in `swdbg-artwork.json`.
|
||||
- Priority 1 (uniques/leaders): 14–18, 28–35, 40, 49–52, 54
|
||||
- Priority 2 (common starters): 0–5, 7–9, 22–24, 26–27
|
||||
- Priority 3 (remaining Empire/Rebel commons): 10–13, 19–21, 36–39
|
||||
- Priority 4 (neutral/outer rim): 6, 41–48, 53
|
||||
- [ ] `swdbg-bases-art.png` — 1100×600 (5×2 grid), 220×300 per frame, 10 frames (0–9); then set `baseSheet.path` in `swdbg-artwork.json`.
|
||||
- [ ] `game-icons.png` frame 78 — 44×44 menu icon.
|
||||
|
||||
The frame→id mappings for both sheets are already filled in in
|
||||
`public/data/swdbg-artwork.json` — once you drop the PNGs in and set the two
|
||||
`path` fields, art appears with no code changes.
|
||||
|
|
@ -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.
|
||||
|
|
@ -84,6 +84,8 @@ import PaiGowPokerGame from './games/paigow/PaiGowPokerGame.js';
|
|||
import SpireClimbGame from './games/spireclimb/SpireClimbGame.js';
|
||||
import AzulGame from './games/azul/AzulGame.js';
|
||||
import JumbleGame from './games/jumble/JumbleGame.js';
|
||||
import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js';
|
||||
import SWDBGGame from './games/swdbg/SWDBGGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -181,6 +183,8 @@ const config = {
|
|||
SpireClimbGame,
|
||||
AzulGame,
|
||||
JumbleGame,
|
||||
DungeonBossGame,
|
||||
SWDBGGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
// Skill control: pips always show the level; the +/- buttons appear only
|
||||
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul'].includes(this.gameDef.slug)) {
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes', 'mahjong', 'geniussquare', 'azul', 'dungeonboss', 'swdbg'].includes(this.gameDef.slug)) {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('katamino', 'data/katamino.json');
|
||||
this.load.json('bookwork', 'data/bookwork.json');
|
||||
this.load.json('spireclimb-artwork', 'data/spireclimb-artwork.json');
|
||||
this.load.json('dungeonboss-artwork', 'data/dungeonboss-artwork.json');
|
||||
this.load.json('swdbg-cards', 'data/swdbg-cards.json');
|
||||
this.load.json('swdbg-artwork', 'data/swdbg-artwork.json');
|
||||
this.load.json('jumble', 'data/jumble.json');
|
||||
this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png');
|
||||
this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png');
|
||||
|
|
@ -225,9 +228,19 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
const scSheets = [scArt?.cardSheet, scArt?.creatureSheet, ...Object.values(scArt?.playerSheets || {})]
|
||||
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
|
||||
|
||||
if (toLoad.length > 0 || scSheets.length > 0) {
|
||||
// Dungeon Boss drop-in spritesheets, same contract as Spire Climb's.
|
||||
const dbArt = this.cache.json.get('dungeonboss-artwork');
|
||||
const dbSheets = [dbArt?.roomSheet, dbArt?.heroSheet, dbArt?.spellSheet, dbArt?.bossSheet]
|
||||
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
|
||||
|
||||
// Star Wars Deckbuilder drop-in spritesheets, same contract.
|
||||
const swArt = this.cache.json.get('swdbg-artwork');
|
||||
const swSheets = [swArt?.cardSheet, swArt?.baseSheet]
|
||||
.filter((s) => s && s.path && s.key && !this.textures.exists(s.key));
|
||||
|
||||
if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0) {
|
||||
for (const asset of toLoad) this.load.image(asset.key, asset.path);
|
||||
for (const s of scSheets) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight });
|
||||
for (const s of [...scSheets, ...dbSheets, ...swSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight });
|
||||
await new Promise((resolve) => {
|
||||
this.load.once('complete', resolve);
|
||||
this.load.start();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue