diff --git a/assets/info/sw-force-cards.csv b/assets/info/sw-force-cards.csv new file mode 100644 index 0000000..f0ed4be --- /dev/null +++ b/assets/info/sw-force-cards.csv @@ -0,0 +1,56 @@ +Title,Cost,Attack,Resources,Force,Type,Trait,Ability,Bounty Value,Bounty Rewards,Count,Hit Points,Aligned,Hero +Z-95 Headhunter,1,2,,,Unit,Fighter,"If your opponent has a capital ship in play, draw 1 card",,,2,,Neutral,no +Jawa Scavenger,1,,2,,Unit,,Exile this unit to purchase a card from the galaxy discard pile as if it were in the galaxy row,,,2,,Neutral,no +Rodian Gunslinger,2,2,,,Unit,Bounty Hunter,This unit gains 2 attack while attacking a target in the galaxy row,,,2,,Neutral,no +Kel Dor Mystic,2,,,2,Unit,,Exile this unit to exile 1 card from your hand or discard pile,,,2,,Neutral,no +Lobot,3,,,,Unit,Officer,"When you play Lobot, choose: He gains 2 attack, 2 resources, or 2 force this turn",,,1,,Neutral,yes +Bossk,3,3,,,Unit,Bounty Hunter,"When Bossk defeats a target in the galaxy row, gain 1 force",,,1,,Neutral,yes +Fang Fighter,3,3,,,Unit,Fighter,"When you purchase this unit, add it to your hand (and draw 1 card if the force is with you)",,,2,,Neutral,no +Twi’lek Smuggler,3,,3,,Unit,Scoundrel,Place the next card you purchase this turn on top of your deck.,,,2,,Neutral,no +C-Roc Cruiser,3,,1,,Capital Ship,,Discard 1 card from your hand to repair 3 damage from your base.,,,2,3,Neutral,no +Dengar,4,4,,,Unit,Bounty Hunter,"When Dengar defeats a target in the galaxy row, gain 2 resources",,,1,,Neutral,yes +Quarren Mercenary,4,4,,,Unit,Trooper,"When you purchase this unit, exile 1 card from your hand or discard pile (up to 2 cards instead if the Force is with you)",,,2,,Neutral,no +HWK-290,4,,4,,Unit,Transport,Exile this unit to repair 4 damage from your base.,,,2,,Neutral,no +Blockade Runner,4,1,1,,Capital Ship,,,,,3,4,Neutral,no +IG-88,5,5,,,Unit,"Bounty Hunter, Droid","When IG-88 defeats a target in the galaxy row, exile 1 card from your hand or discard pile.",,,1,,Neutral,yes +Nebulon-B Frigate,5,,,,Capital Ship,,"Chose: Repair 3 damage from your base, or gain 3 resources.",,,2,5,Neutral,no +Lando Calrissian,6,3,3,,Unit,Scoundrel,"Draw 1 card. If the Force is with you, your opponent discards 1 card from their hand.",,,1,,Neutral,yes +Jabba’s Sail Barge,7,4,3,,Unit,Vehicle,Add a Bounty Hunter from your discard pile to your hand.,,,1,,Neutral,yes +Jabba the Hutt,8,2,2,2,Unit,Scoundrel,Exile 1 card from your hand to draw 1 card (2 cards instead if the Force is with you),,,1,,Neutral,yes +Y-Wing,1,2,,,Unit,Fighter,Exile this unit to deal 2 damage to your opponent’s base or a capital ship they have in play.,1,2 Resources,2,,Rebel Alliance,no +Baze Malbus,2,2,,,Unit,Trooper,Baze Malbus gains 1 attack for each Rebel Base in your opponent’s victory pile,2,1 Force,1,,Rebel Alliance,yes +Snowspeeder,2,2,,,Unit,Vehicle,Your opponent discards 1 card from their hand,2,Exile 1 card from your hand or discard pile,2,,Rebel Alliance,no +Duros Spy,2,,2,,Unit,Trooper,Your opponent must choose: Either they discard 1 card from their hand or you gain 1 Force,2,Exile 1 card from your hand or discard pile,2,,Rebel Alliance,no +Rebel Transport,2,,,,Capital Ship,,"Chose: Repair 2 damage from your base, or gain 1 resource",,,2,2,Rebel Alliance,no +Chirrut Imwe,3,,,2,Unit,Trooper,"While the Force is with you, Chirrut Imwe gains 2 attack",3,2 Force,1,,Rebel Alliance,no +Rebel Commando,3,3,,,Unit,Trooper,Your opponent discards 1 card from their hand (at random if the Force is with you) ,3,2 Force,2,,Rebel Alliance,no +X-Wing,3,3,,,Unit,Fighter,"If the Force is with you, draw 1 card.",3,3 Resources,3,,Rebel Alliance,no +Chewbacca,4,5,,,Unit,Scoundrel,"If you have another Hero unit in play, draw 1 card",4,3 Force,1,,Rebel Alliance,yes +Jyn Erso,4,4,,,Unit,Scoundrel,"Look at your opponent’s hand. If the Force is with you, place 1 card from their hand on top of their deck.",4,3 Force,1,,Rebel Alliance,yes +U-Wing,4,,3,,Unit,Transport,"If the Force is with you, repair 3 damage from your base.",4,4 Resources,2,,Rebel Alliance,no +Hammerhead Corvette,4,,2,,Capital Ship,,"Exile this capital ship to destroy a capital ship you opponent has in play, or an enemy capital ship in the galaxy row.",,,2,4,Rebel Alliance,no +Han Solo,5,3,2,,Unit,Scoundrel,Draw 1 card (2 cards instead if you have the Millennium Falcon in play),5,"3 Resources, and 2 Force",1,,Rebel Alliance,yes +Cassian Andor,5,5,,,Unit,Trooper,"When Cassian Andor defeats a target in the galaxy row, your opponent discards 1 card from their hand.",5,"3 Resources, and 2 Force",1,,Rebel Alliance,yes +B-Wing,5,5,,,Unit,Fighter,Your opponent must choose: Either they discard 1 card from their hand or this unit gains 2 attack,5,Exile up to 2 cards from your hand or discard pile.,2,,Rebel Alliance,no +Princess Leia,6,2,2,2,Unit,Officer,"Purchase a Rebel card from the galaxy row for free. If the Force is with you, place that card on top of your deck.",6,"3 Resources, and 3 Force",1,,Rebel Alliance,yes +Mon Calamari Cruiser,6,3,,,Capital Ship,,,,,2,6,Rebel Alliance,no +Millennium Falcon,7,5,2,,Unit,Transport,Add a unique Hero unit from your discard pile to your hand,7,Purchase a card of your faction for free.,1,,Rebel Alliance,yes +Luke Skywalker,8,6,,2,Unit,Jedi,"If the Force is with you, destroy a capital ship your opponent has in play.",8,"4 Resources, and 4 Force",1,,Rebel Alliance,yes +Tie Fighter,1,2,,,Unit,Fighter,"If you have a capital ship in play, draw 1 card.",1,1 Resource,3,,Empire,no +Admiral Piett,2,,2,,Unit,Officer,"While Admiral Piett is in play, each of your capital ships gains 1 attack",2,1 Force,1,,Empire,yes +Tie Bomber,2,2,,,Unit,Fighter,Discard 1 card from the galaxy row.,2,Exile 1 card from your hand or discard pile,2,,Empire,no +Scout Trooper,2,,2,,Unit,Trooper,"Reveal the top card of the galaxy deck. If it is an Empire card, gain 1 Force. If it is an enemy card, discard it.",2,Exile 1 card from your hand or discard pile,2,,Empire,no +Death Trooper,3,3,,,Unit,Trooper,"While the Force is with you, this unit gains 2 attack",3,2 Force,2,,Empire,no +Tie Interceptor,3,3,,,Unit,Fighter,"Reveal the top card of the galaxy deck. If it is an Empire card, draw 1 card. If it is an enemy card, discard it.",3,3 Resources,2,,Empire,no +Gonzanti Cruiser,3,,2,,Capital Ship,,Discard 1 card from your hand to draw 1 card,,,3,3,Empire,no +Moff Jerjerrod,4,2,2,,Unit,Officer,"Look at the top card of the galaxy deck. If the Force is with you, you may swap that card with a card from the galaxy row.",4,3 Force,1,,Empire,yes +General Veers,4,4,,,Unit,Officer,"If you have a Trooper or Vehicle in play, draw 1 card",4,3 Force,1,,Empire,yes +AT-ST,4,4,,,Unit,Vehicle,Discard 1 card from the galaxy row.,4,Exile up to 2 cards from your hand or discard pile.,2,,Empire,no +Landing Craft,4,,,,Unit,Transport,"Choose: Gain 4 resources, or repair 4 damage from your base.",4,4 Resources,2,,Empire,no +Director Krennic,5,3,2,,Unit,Officer,Draw 1 card (2 cards instead if your base is the Death Star),5,"3 Resources, and 2 Force",1,,Empire,yes +Boba Fett,5,5,,,Unit,Bounty Hunter,"When Boba Fett defeates a target in the galaxy row, draw 1 card.",5,"3 Resources, and 2 Force",1,,Empire,yes +Imperial Carrier,5,,3,,Capital Ship,"While Imperial Carrier is in play, each of your fighter units gain 1 attack",,,,2,5,Empire,no +Grand Moff Tarkin,6,2,2,2,Unit,Officer,"Add an Empire card from the galaxy row to your hand, You must exile that card at the end of your turn.",6,"3 Resources, and 3 Force",1,,Empire,yes +AT-AT,6,6,,,Unit,Vehicle,Add a Trooper from your discard pile to your hand.,6,Purchase a card of your faction for free.,1,,Empire,no +Star Destroyer,7,4,,,Capital Ship,,,,,2,7,Empire,no +Darth Vader,8,6,,,Unit,Sith,"While the Force is with you, Darth Vader gains 4 attack.",8,"4 Resources, and 4 Force",1,,Empire,yes diff --git a/data/swdbg-artwork.json b/data/swdbg-artwork.json index b5425be..7b76503 100644 --- a/data/swdbg-artwork.json +++ b/data/swdbg-artwork.json @@ -21,18 +21,21 @@ "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, + "gozanticruiser": 19, "imperialcarrier": 20, "stardestroyer": 21, - "rebelpathfinder": 22, "ywing": 23, "snowspeeder": 24, "durosspy": 25, - "xwing": 26, "rebelcommando": 27, "cassianandor": 28, "k2so": 29, + "ywing": 23, "snowspeeder": 24, "durosspy": 25, + "xwing": 26, "rebelcommando": 27, "cassianandor": 28, "chirrutimwe": 30, "jynerso": 31, "chewbacca": 32, "hansolo": 33, "princessleia": 34, "lukeskywalker": 35, "blockaderunner": 36, - "hammerheadcorvette": 37, "medicalfrigate": 38, "moncalamaricruiser": 39, + "hammerheadcorvette": 37, "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 + "bobafett": 51, "jabbathehutt": 52, "crocruiser": 53, "jabbassailbarge": 54, + "lobot": 55, "dengar": 56, "nebulonbfrigate": 57, "landocalrissian": 58, + "bazemalbus": 59, "rebeltransport": 60, "uwing": 61, "bwing": 62, + "admiralpiett": 63, "moffjerjerrod": 64, "landingcraft": 65 }, "bases": { diff --git a/data/swdbg-cards.json b/data/swdbg-cards.json index c36b680..c108f51 100644 --- a/data/swdbg-cards.json +++ b/data/swdbg-cards.json @@ -4,62 +4,102 @@ "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.", + "Galaxy cards were transcribed from the owner's physical set", + "(assets/info/sw-force-cards.csv). tools/verifySWDBG.js cross-checks this", + "file against that CSV, so keep the two in sync when editing.", "", "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", + " unique true = Hero card (CSV 'Hero' column): gold inset, and what", + " Hero-matters abilities (Chewbacca, Millennium Falcon) look for", " 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", + " count copies in the 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", + " units only; neutral cards and capital ships use null)", + " reward what the defeater gains:", + " { resources?, force?, draw?, exile?, freePurchase? } or null", + " exile: n = exile up to n cards from your hand/discard", + " freePurchase: true = purchase a card of your faction for free", " 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?, requiresCapital? }", - " 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", - " requiresCapital: true = usable only while you have a capital ship in play", + "ABILITY SHAPE: { timing, op, ...params, cost?, ...gates? }", + " 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 THIS card helps defeat a galaxy-row", + " target ('When X defeats ...')", + " 'onPurchase' = triggers when you purchase this card", + " 'onReveal' = bases only: when the base is revealed", + " cost paid before the op resolves; any of:", + " { exileSelf: true } exile this card from play", + " { exileFromHand: 1 } exile a card from your hand", + " { discardFromHand: 1 } discard a card from your hand", + " GATES (usable/fires only while true; combine freely):", + " requiresForce: true the Force is with you", + " requiresCapital: true you have a capital ship in play", + " requiresOppCapital: true your OPPONENT has a capital ship in play", + " requiresTraitInPlay: [...] you have a card with any listed trait in play", + " requiresOtherHero: true you have another Hero unit in play", " nForce / amountForce = upgraded number while the Force is with you", "", "SUPPORTED OPS (the engine understands exactly these):", - " draw{n,nForce} draw cards", + " draw{n,nForce,nBase,nWithCard} draw cards. nBase:{base,n} = n while your", + " base is that id; nWithCard:{id,n} = n while the", + " named card is in your play area", " 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)", + " dealDamage{n} deal n damage to the enemy base OR one enemy", + " capital ship in play (attacker picks)", " repairBase{n} remove damage from your base", " oppDiscard{n,randomForce} opponent discards (random while Force is w/ you)", + " oppChoice{options} OPPONENT picks one option; each option is", + " {kind:'discardSelf'} (they discard 1),", + " {kind:'giveForce',n} (you gain n Force), or", + " {kind:'attackBoost',n} (this card gains n attack)", " 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", + " destroyCapital{scope} destroy a capital ship. scope 'oppPlay' = only", + " ones your opponent has in play; 'any' = also", + " enemy capitals in the galaxy row", + " exileCards{n,nForce,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", + " buyGalaxyDiscard{} purchase a card from the galaxy discard pile,", + " paying its cost, as if it were in the row", + " topdeckNextPurchase{} the next card you purchase this turn goes on", + " top of your deck instead of your discard", + " recoverDiscard{filter} return a card from your discard to your hand;", + " filter {trait:'X'} or {heroUnit:true}", " 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", + " lookTopSwap{} look at top of galaxy deck; while the Force is", + " with you, you may swap it with a galaxy-row card", + " lookOppHand{forceTopdeck} look at opponent's hand; while the Force is with", + " you, put 1 card from it on top of their deck", + " takeRowTemp{faction} add a row card of that faction to your hand;", + " it is exiled at the end of your turn", + " revealTop{onMine} reveal top of galaxy deck; if it matches your", + " faction resolve onMine ({op:'gainForce'|'draw'|", + " 'gainResources', n}), if enemy you may discard", + " it, if neutral nothing happens", + " choose{options:[...]} pick one of the sub-abilities (2 or more)", + " multi{effects:[...]} resolve each sub-effect in order; a sub-effect", + " with its own gate (e.g. requiresForce) is", + " skipped when the gate fails", " traitBoost{trait,amount} constant: your units with trait get +attack", + " typeBoost{cardType,amount} constant: your cards of that type ('capital'", + " or 'unit') 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", + " selfBoostPerBaseLost{amount} constant: +attack per base you have lost", " capitalDiscount{n} constant (bases): your capital ships cost less", " onPlayTraitAttack{trait,amount} constant (bases): played units with the", " trait gain +attack", @@ -112,152 +152,173 @@ }, "cards": [ - { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 2, "resources": 0, "force": 0, "count": 5, "target": 1, "reward": { "resources": 1 }, - "ability": { "timing": "action", "op": "draw", "n": 1, "requiresCapital": true }, - "text": "If you have a capital ship in play, draw 1 card." }, - { "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 2, "resources": 2, "force": 0, "count": 3, "target": 2, "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": "constant", "op": "selfBoostWhileForce", "amount": 4 }, - "text": "Darth Vader gains 4 attack while the Force is with you." }, - { "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": 3, "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": 4, "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": ["Trooper"], "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": 5, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, - "ability": { "timing": "onBounty", "op": "oppDiscard", "n": 1 }, - "text": "When Cassian Andor defeats a target in the galaxy row, your opponent discards 1 card from their hand." }, - { "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": 4, "force": 4 }, - "ability": { "timing": "action", "op": "destroyCapital", "requiresForce": true }, - "text": "If the Force is with you, destroy a capital ship your opponent has in play." }, - { "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, + { "id": "z95headhunter", "name": "Z-95 Headhunter", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresOppCapital": true }, + "text": "If your opponent has a capital ship in play, draw 1 card." }, + { "id": "jawascavenger", "name": "Jawa Scavenger", "faction": "neutral", "type": "unit", "unique": false, "traits": [], "cost": 1, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "buyGalaxyDiscard", "cost": { "exileSelf": true } }, + "text": "Exile this unit to purchase a card from the galaxy discard pile as if it were in the galaxy row." }, + { "id": "rodiangunslinger", "name": "Rodian Gunslinger", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Bounty Hunter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "rowBonus", "amount": 2 }, + "text": "This unit gains 2 attack while attacking a target in the galaxy row." }, + { "id": "keldormystic", "name": "Kel Dor Mystic", "faction": "neutral", "type": "unit", "unique": false, "traits": [], "cost": 2, "attack": 0, "resources": 0, "force": 2, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true, "cost": { "exileSelf": true } }, + "text": "Exile this unit to exile 1 card from your hand or discard pile." }, + { "id": "lobot", "name": "Lobot", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 3, "attack": 0, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onPlay", "op": "choose", "options": [ { "op": "gainAttack", "n": 2 }, { "op": "gainResources", "n": 2 }, { "op": "gainForce", "n": 2 } ] }, + "text": "When you play Lobot, choose: he gains 2 attack, 2 resources, or 2 Force this turn." }, + { "id": "bossk", "name": "Bossk", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onBounty", "op": "gainForce", "n": 1 }, + "text": "When Bossk defeats a target in the galaxy row, gain 1 Force." }, + { "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": { "timing": "onPurchase", "op": "multi", "effects": [ { "op": "purchaseToHand" }, { "op": "draw", "n": 1, "requiresForce": true } ] }, + "text": "When you purchase this unit, add it to your hand (and draw 1 card if the Force is with you)." }, + { "id": "twileksmuggler", "name": "Twi'lek Smuggler", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Scoundrel"], "cost": 3, "attack": 0, "resources": 3, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "topdeckNextPurchase" }, + "text": "Place the next card you purchase this turn on top of your deck." }, + { "id": "crocruiser", "name": "C-ROC Cruiser", "faction": "neutral", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 3, "attack": 0, "resources": 1, "force": 0, "hp": 3, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "repairBase", "n": 3, "cost": { "discardFromHand": 1 } }, + "text": "Discard 1 card from your hand to repair 3 damage from your base." }, + { "id": "dengar", "name": "Dengar", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "onBounty", "op": "gainResources", "n": 2 }, + "text": "When Dengar defeats a target in the galaxy row, gain 2 resources." }, + { "id": "quarrenmercenary", "name": "Quarren Mercenary", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "onPurchase", "op": "exileCards", "n": 1, "nForce": 2, "optional": true }, + "text": "When you purchase this unit, exile 1 card from your hand or discard pile (up to 2 cards instead if the Force is with you)." }, + { "id": "hwk290", "name": "HWK-290", "faction": "neutral", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 4, "attack": 0, "resources": 4, "force": 0, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "repairBase", "n": 4, "cost": { "exileSelf": true } }, + "text": "Exile this unit to repair 4 damage from your base." }, + { "id": "blockaderunner", "name": "Blockade Runner", "faction": "neutral", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 3, "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": 4, "attack": 0, "resources": 2, "force": 0, "hp": 4, "count": 1, "target": null, "reward": null, - "ability": { "timing": "action", "op": "destroyCapital", "cost": { "exileSelf": true } }, - "text": "Exile this card to destroy a capital ship your opponent has in play, or an enemy capital ship in the galaxy row." }, - { "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": 6, "attack": 3, "resources": 1, "force": 0, "hp": 6, "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, "cost": { "exileSelf": true } }, - "text": "Exile this card to exile 1 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": 5, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, + { "id": "ig88", "name": "IG-88", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Bounty Hunter", "Droid"], "cost": 5, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": null, "reward": null, "ability": { "timing": "onBounty", "op": "exileCards", "n": 1, "optional": true }, "text": "When IG-88 defeats a target in the galaxy row, exile 1 card from your hand or discard pile." }, - { "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": "nebulonbfrigate", "name": "Nebulon-B Frigate", "faction": "neutral", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 5, "attack": 0, "resources": 0, "force": 0, "hp": 5, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "choose", "options": [ { "op": "repairBase", "n": 3 }, { "op": "gainResources", "n": 3 } ] }, + "text": "Choose: repair 3 damage from your base, or gain 3 resources." }, + { "id": "landocalrissian", "name": "Lando Calrissian", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 6, "attack": 3, "resources": 3, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "multi", "effects": [ { "op": "draw", "n": 1 }, { "op": "oppDiscard", "n": 1, "requiresForce": true } ] }, + "text": "Draw 1 card. If the Force is with you, your opponent discards 1 card from their hand." }, + { "id": "jabbassailbarge", "name": "Jabba's Sail Barge", "faction": "neutral", "type": "unit", "unique": true, "traits": ["Vehicle"], "cost": 7, "attack": 4, "resources": 3, "force": 0, "count": 1, "target": null, "reward": null, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": { "trait": "Bounty Hunter" } }, + "text": "Add a Bounty Hunter from your discard pile to your hand." }, { "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, + + { "id": "ywing", "name": "Y-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": 1, "reward": { "resources": 2 }, + "ability": { "timing": "action", "op": "dealDamage", "n": 2, "cost": { "exileSelf": true } }, + "text": "Exile this unit to deal 2 damage to your opponent's base or a capital ship they have in play." }, + { "id": "bazemalbus", "name": "Baze Malbus", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 1, "target": 2, "reward": { "force": 1 }, + "ability": { "timing": "constant", "op": "selfBoostPerBaseLost", "amount": 1 }, + "text": "Baze Malbus gains 1 attack for each Rebel base in your opponent's victory pile." }, + { "id": "snowspeeder", "name": "Snowspeeder", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": 2, "reward": { "exile": 1 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1 }, + "text": "Your opponent discards 1 card from their hand." }, + { "id": "durosspy", "name": "Duros Spy", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": 2, "reward": { "exile": 1 }, + "ability": { "timing": "action", "op": "oppChoice", "options": [ { "kind": "discardSelf" }, { "kind": "giveForce", "n": 1 } ] }, + "text": "Your opponent must choose: either they discard 1 card from their hand, or you gain 1 Force." }, + { "id": "rebeltransport", "name": "Rebel Transport", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 2, "attack": 0, "resources": 0, "force": 0, "hp": 2, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "choose", "options": [ { "op": "repairBase", "n": 2 }, { "op": "gainResources", "n": 1 } ] }, + "text": "Choose: repair 2 damage from your base, or gain 1 resource." }, + { "id": "chirrutimwe", "name": "Chirrut Îmwe", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 0, "resources": 0, "force": 2, "count": 1, "target": 3, "reward": { "force": 2 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 2 }, + "text": "While the Force is with you, Chirrut Îmwe gains 2 attack." }, + { "id": "rebelcommando", "name": "Rebel Commando", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 2, "target": 3, "reward": { "force": 2 }, + "ability": { "timing": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, + "text": "Your opponent discards 1 card from their hand (at random if the Force is with you)." }, + { "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": 3 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresForce": true }, + "text": "If the Force is with you, draw 1 card." }, + { "id": "chewbacca", "name": "Chewbacca", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 4, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": 4, "reward": { "force": 3 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresOtherHero": true }, + "text": "If you have another Hero unit in play, draw 1 card." }, + { "id": "jynerso", "name": "Jyn Erso", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": 4, "reward": { "force": 3 }, + "ability": { "timing": "action", "op": "lookOppHand", "forceTopdeck": true }, + "text": "Look at your opponent's hand. If the Force is with you, place 1 card from their hand on top of their deck." }, + { "id": "uwing", "name": "U-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 4, "attack": 0, "resources": 3, "force": 0, "count": 2, "target": 4, "reward": { "resources": 4 }, + "ability": { "timing": "action", "op": "repairBase", "n": 3, "requiresForce": true }, + "text": "If the Force is with you, repair 3 damage from your base." }, + { "id": "hammerheadcorvette", "name": "Hammerhead Corvette", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 0, "resources": 2, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, + "ability": { "timing": "action", "op": "destroyCapital", "scope": "any", "cost": { "exileSelf": true } }, + "text": "Exile this capital ship to destroy a capital ship your opponent has in play, or an enemy capital ship in the galaxy row." }, + { "id": "hansolo", "name": "Han Solo", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Scoundrel"], "cost": 5, "attack": 3, "resources": 2, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "nWithCard": { "id": "millenniumfalcon", "n": 2 } }, + "text": "Draw 1 card (2 cards instead if you have the Millennium Falcon in play)." }, + { "id": "cassianandor", "name": "Cassian Andor", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper"], "cost": 5, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "onBounty", "op": "oppDiscard", "n": 1 }, + "text": "When Cassian Andor defeats a target in the galaxy row, your opponent discards 1 card from their hand." }, + { "id": "bwing", "name": "B-Wing", "faction": "rebel", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 5, "attack": 5, "resources": 0, "force": 0, "count": 2, "target": 5, "reward": { "exile": 2 }, + "ability": { "timing": "action", "op": "oppChoice", "options": [ { "kind": "discardSelf" }, { "kind": "attackBoost", "n": 2 } ] }, + "text": "Your opponent must choose: either they discard 1 card from their hand, or this unit gains 2 attack." }, + { "id": "princessleia", "name": "Princess Leia", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 6, "attack": 2, "resources": 2, "force": 2, "count": 1, "target": 6, "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": "moncalamaricruiser", "name": "Mon Calamari Cruiser", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 6, "attack": 3, "resources": 0, "force": 0, "hp": 6, "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, + "text": "The backbone of the Rebel fleet, crewed by the galaxy's finest shipwrights." }, + { "id": "millenniumfalcon", "name": "Millennium Falcon", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Transport"], "cost": 7, "attack": 5, "resources": 2, "force": 0, "count": 1, "target": 7, "reward": { "freePurchase": true }, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": { "heroUnit": true } }, + "text": "Add a Hero unit from your discard pile to your hand." }, + { "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": 4, "force": 4 }, + "ability": { "timing": "action", "op": "destroyCapital", "scope": "oppPlay", "requiresForce": true }, + "text": "If the Force is with you, destroy a capital ship your opponent has in play." }, + + { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 2, "resources": 0, "force": 0, "count": 3, "target": 1, "reward": { "resources": 1 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresCapital": true }, + "text": "If you have a capital ship in play, draw 1 card." }, + { "id": "admiralpiett", "name": "Admiral Piett", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 1, "target": 2, "reward": { "force": 1 }, + "ability": { "timing": "constant", "op": "typeBoost", "cardType": "capital", "amount": 1 }, + "text": "While Admiral Piett is in play, each of your capital ships gains 1 attack." }, + { "id": "tiebomber", "name": "TIE Bomber", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 2, "target": 2, "reward": { "exile": 1 }, + "ability": { "timing": "action", "op": "discardRow" }, + "text": "Discard 1 card from the galaxy row." }, + { "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 2, "target": 2, "reward": { "exile": 1 }, + "ability": { "timing": "action", "op": "revealTop", "onMine": { "op": "gainForce", "n": 1 } }, + "text": "Reveal the top card of the galaxy deck. If it is an Empire card, gain 1 Force. If it is an enemy 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": 2, "target": 3, "reward": { "force": 2 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 2 }, + "text": "While the Force is with you, this unit gains 2 attack." }, + { "id": "tieinterceptor", "name": "TIE Interceptor", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 3, "attack": 3, "resources": 0, "force": 0, "count": 2, "target": 3, "reward": { "resources": 3 }, + "ability": { "timing": "action", "op": "revealTop", "onMine": { "op": "draw", "n": 1 } }, + "text": "Reveal the top card of the galaxy deck. If it is an Empire card, draw 1 card. If it is an enemy card, you may discard it." }, + { "id": "gozanticruiser", "name": "Gozanti Cruiser", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 3, "attack": 0, "resources": 2, "force": 0, "hp": 3, "count": 3, "target": null, "reward": null, + "ability": { "timing": "action", "op": "draw", "n": 1, "cost": { "discardFromHand": 1 } }, + "text": "Discard 1 card from your hand to draw 1 card." }, + { "id": "moffjerjerrod", "name": "Moff Jerjerrod", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 4, "attack": 2, "resources": 2, "force": 0, "count": 1, "target": 4, "reward": { "force": 3 }, + "ability": { "timing": "action", "op": "lookTopSwap" }, + "text": "Look at the top card of the galaxy deck. If the Force is with you, you may swap that card with a card from the galaxy row." }, + { "id": "generalveers", "name": "General Veers", "faction": "empire", "type": "unit", "unique": true, "traits": ["Officer"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 1, "target": 4, "reward": { "force": 3 }, + "ability": { "timing": "action", "op": "draw", "n": 1, "requiresTraitInPlay": ["Trooper", "Vehicle"] }, + "text": "If you have a Trooper or Vehicle in play, draw 1 card." }, + { "id": "atst", "name": "AT-ST", "faction": "empire", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 4, "attack": 4, "resources": 0, "force": 0, "count": 2, "target": 4, "reward": { "exile": 2 }, + "ability": { "timing": "action", "op": "discardRow" }, + "text": "Discard 1 card from the galaxy row." }, + { "id": "landingcraft", "name": "Landing Craft", "faction": "empire", "type": "unit", "unique": false, "traits": ["Transport"], "cost": 4, "attack": 0, "resources": 0, "force": 0, "count": 2, "target": 4, "reward": { "resources": 4 }, + "ability": { "timing": "action", "op": "choose", "options": [ { "op": "gainResources", "n": 4 }, { "op": "repairBase", "n": 4 } ] }, + "text": "Choose: gain 4 resources, or repair 4 damage from your base." }, + { "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": "bobafett", "name": "Boba Fett", "faction": "empire", "type": "unit", "unique": true, "traits": ["Bounty Hunter"], "cost": 5, "attack": 5, "resources": 0, "force": 0, "count": 1, "target": 5, "reward": { "resources": 3, "force": 2 }, + "ability": { "timing": "onBounty", "op": "draw", "n": 1 }, + "text": "When Boba Fett defeats a target in the galaxy row, draw 1 card." }, + { "id": "imperialcarrier", "name": "Imperial Carrier", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 5, "attack": 0, "resources": 3, "force": 0, "hp": 5, "count": 2, "target": null, "reward": null, + "ability": { "timing": "constant", "op": "traitBoost", "trait": "Fighter", "amount": 1 }, + "text": "While Imperial Carrier is in play, each of your Fighter units gains 1 attack." }, + { "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": 6, "reward": { "resources": 3, "force": 3 }, + "ability": { "timing": "action", "op": "takeRowTemp", "faction": "empire" }, + "text": "Add an Empire card from the galaxy row to your hand. You must exile that card at the end of your turn." }, + { "id": "atat", "name": "AT-AT", "faction": "empire", "type": "unit", "unique": false, "traits": ["Vehicle"], "cost": 6, "attack": 6, "resources": 0, "force": 0, "count": 1, "target": 6, "reward": { "freePurchase": true }, + "ability": { "timing": "action", "op": "recoverDiscard", "filter": { "trait": "Trooper" } }, + "text": "Add a Trooper from your discard pile to your hand." }, + { "id": "stardestroyer", "name": "Star Destroyer", "faction": "empire", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 7, "attack": 4, "resources": 0, "force": 0, "hp": 7, "count": 2, "target": null, "reward": null, "ability": null, - "text": "Jabba's sail barge: pleasure craft, prison, and gallows in one." } + "text": "The Imperial Navy's argument, delivered at length." }, + { "id": "darthvader", "name": "Darth Vader", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 6, "resources": 0, "force": 0, "count": 1, "target": 8, "reward": { "resources": 4, "force": 4 }, + "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 4 }, + "text": "While the Force is with you, Darth Vader gains 4 attack." } ], "bases": { diff --git a/src/games/swdbg/SWDBGAI.js b/src/games/swdbg/SWDBGAI.js index 2ca6880..663a1a8 100644 --- a/src/games/swdbg/SWDBGAI.js +++ b/src/games/swdbg/SWDBGAI.js @@ -60,7 +60,7 @@ function chooseTurn(view, seat, skill, rnd) { 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; + return ab?.timing === 'action' && (ab.cost?.exileFromHand || ab.cost?.discardFromHand) && !e.used; }); let playable = p.hand; if (needsFodder && p.hand.length === 1) playable = []; @@ -77,7 +77,7 @@ function chooseTurn(view, seat, skill, rnd) { 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) { + if (def.ability?.cost?.exileFromHand || def.ability?.cost?.discardFromHand) { const fodder = worstHandCard(view, seat); if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal); action.costUid = fodder.uid; @@ -107,7 +107,8 @@ function nextAfterAbilities(view, seat, skill, rnd, legal) { 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 rewardV = (r.resources || 0) + (r.force || 0) * 1.2 + (r.draw || 0) * 1.8 + + (r.exile || 0) * 0.8 + (r.freePurchase ? 2.5 : 0); 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; @@ -241,6 +242,54 @@ function chooseTarget(view, seat, skill, rnd, decision) { const discard = enemy || junk; return cands.find((c) => c.v === (discard ? 'discard' : 'keep')) || cands[0]; } + case 'dealDamage': { + const o = foe(view, seat); + for (const c of cands) { + if (c.kind === 'oppBase') { scored.push({ choice: c, s: 2 }); continue; } + const e = o.capitals.find((x) => x.card.uid === c.uid); + if (!e) continue; + const def = cardDef(e.card); + const kills = e.damage + 2 >= def.hp; // Y-Wing deals 2 + scored.push({ choice: c, s: kills ? cardValue(def) + 2 : 1 }); + } + break; + } + case 'buyGalaxyDiscard': + for (const c of cands) { + const def = cardDef(view.galaxy.discard.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: cardValue(def) - def.cost * 0.35 }); + } + if (skipOk) scored.push({ choice: null, s: 0.5 }); + break; + case 'takeRowTemp': + // The card is played this turn and exiled after — raw tempo only. + for (const c of cands) { + const def = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + scored.push({ choice: c, s: (def.attack || 0) + (def.resources || 0) + (def.force || 0) }); + } + if (skipOk) scored.push({ choice: null, s: 0.5 }); + break; + case 'oppTopdeck': + // Strand the opponent's best card on top of their deck. + for (const c of cands) scored.push({ choice: c, s: cardValue(cardDef(c.id)) }); + break; + case 'lookTopSwap': { + const top = decision.peekId ? cardDef(decision.peekId) : null; + for (const c of cands) { + if (c.kind === 'opt') { scored.push({ choice: c, s: 0.5 }); continue; } + if (!top) continue; + const row = cardDef(view.galaxy.row.find((x) => x.uid === c.uid)); + const topMine = top.faction === p.faction; + const rowEnemy = row.faction !== 'neutral' && row.faction !== p.faction; + const rowMine = row.faction === p.faction; + let s = 0; + if (topMine) s += cardValue(top) * 0.7; // surface a card I can buy + if (rowEnemy) s += cardValue(row) * 0.4; // bury an enemy option + if (rowMine) s -= cardValue(row) * 0.8; // don't bury my own buys + scored.push({ choice: c, s }); + } + break; + } default: return decision.optional ? null : cands[0] || null; } @@ -253,20 +302,37 @@ function chooseOppDiscard(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'; +// Victim picks the least painful oppChoice option (Duros Spy, B-Wing). +function chooseOppChoice(view, seat, decision) { + let best = 0, bestPain = Infinity; + decision.options.forEach((option, i) => { + let pain; + switch (option.kind) { + case 'discardSelf': { + const worst = worstHandCard(view, seat); + pain = worst ? cardValue(cardDef(worst)) : Infinity; + break; + } + case 'giveForce': pain = (option.n || 1) * 1.2; break; + case 'attackBoost': pain = (option.n || 1) * 0.9; break; + default: pain = 1; + } + if (pain < bestPain) { bestPain = pain; best = i; } + }); + return best; } function chooseOption(view, seat, skill, rnd, decision) { + const p = me(view, seat); 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; + if (ab.op === 'gainAttack') s = (ab.n || 1) * 0.9; + if (ab.op === 'gainForce') s = (ab.n || 1) * 0.8; + if (ab.op === 'repairBase') s = Math.min(ab.n || 1, p.base ? p.base.damage : 0) * 0.7; return { choice: i, s }; }); return pickScored(scored, skill, rnd) ?? 0; @@ -275,7 +341,7 @@ function chooseOption(view, seat, skill, rnd, decision) { // ── Router ────────────────────────────────────────────────────────────────── // Returns the argument for the matching act* mutator: // turn → action object · chooseBase → baseId · target → ref|null · -// oppDiscard → uid · spyChoice → 'discard'|'force' · chooseOption → idx +// oppDiscard → uid · oppChoice → idx · chooseOption → idx export function decide(view, decision, skill, rnd = Math.random) { const seat = decision.seat; switch (decision.kind) { @@ -283,7 +349,7 @@ export function decide(view, decision, skill, rnd = Math.random) { 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 'oppChoice': return chooseOppChoice(view, seat, decision); case 'chooseOption': return chooseOption(view, seat, skill, rnd, decision); default: return null; } diff --git a/src/games/swdbg/SWDBGData.js b/src/games/swdbg/SWDBGData.js index 29f6c9f..76e32a8 100644 --- a/src/games/swdbg/SWDBGData.js +++ b/src/games/swdbg/SWDBGData.js @@ -15,15 +15,18 @@ export const FACTION_INFO = { // 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', + 'draw', 'gainResources', 'gainForce', 'gainAttack', 'damageBase', 'dealDamage', + 'repairBase', 'oppDiscard', 'oppChoice', 'discardRow', 'destroyCapital', + 'exileCards', 'freePurchase', 'buyGalaxyDiscard', 'topdeckNextPurchase', + 'recoverDiscard', 'lookTop', 'lookTopSwap', 'lookOppHand', 'takeRowTemp', + 'revealTop', 'choose', 'multi', 'purchaseToHand', + 'traitBoost', 'typeBoost', 'rowBonus', 'selfBoostWhileForce', + 'selfBoostWithTrait', 'selfBoostPerBaseLost', 'capitalDiscount', 'onPlayTraitAttack', ]; -export const TIMINGS = ['action', 'constant', 'onPlay', 'onBounty', 'onReveal']; +export const TIMINGS = ['action', 'constant', 'onPlay', 'onBounty', 'onPurchase', 'onReveal']; // Ops that are passive modifiers — never activated as an action. -export const CONSTANT_OPS = ['traitBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', 'capitalDiscount', 'onPlayTraitAttack']; +export const CONSTANT_OPS = ['traitBoost', 'typeBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', 'selfBoostPerBaseLost', 'capitalDiscount', 'onPlayTraitAttack']; let DATA = null; diff --git a/src/games/swdbg/SWDBGGame.js b/src/games/swdbg/SWDBGGame.js index be96828..440e6f9 100644 --- a/src/games/swdbg/SWDBGGame.js +++ b/src/games/swdbg/SWDBGGame.js @@ -20,7 +20,7 @@ import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait. import { loadCardData, cardDef, baseDef, basesFor, getData, FACTION_INFO } from './SWDBGData.js'; import { newGame, pendingDecision, takeEvents, publicView, legalActions, - actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, + actTurn, actChooseBase, actChooseTarget, actOppDiscard, actOppChoice, actChooseOption, entryAttack, forceWith, purchaseCost, FORCE_MAX, } from './SWDBGLogic.js'; import { decide, nextThinkDelay } from './SWDBGAI.js'; @@ -257,7 +257,11 @@ export default class SWDBGGame extends Phaser.Scene { artFor(id, base = false) { const sheet = base ? this.art.baseSheet : this.art.cardSheet; const frame = (base ? this.art.bases : this.art.cards)?.[id]; - if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key)) { + // The frame must actually exist in the loaded texture — a mapping that + // points past the sheet's last row (art not painted yet) falls back to + // procedural rendering instead of Phaser's silent frame-0 substitute. + if (sheet && sheet.key && frame != null && this.textures.exists(sheet.key) + && this.textures.get(sheet.key).has(frame)) { return { key: sheet.key, frame }; } return null; @@ -1204,6 +1208,18 @@ export default class SWDBGGame extends Phaser.Scene { onClick: () => this.onHandClicked(inst), }); if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9); + // Grand Moff Tarkin's borrowed card — flag that it exiles at end of turn. + if (p.tempExileUids?.includes(inst.uid)) { + const tag = this.add.container(cardX, y - chh / 2 + 12); + const tg = this.add.graphics(); + tg.fillStyle(0x3a1f52, 0.95); tg.fillRoundedRect(-58, -9, 116, 18, 5); + tg.lineStyle(1, 0xb06bd8, 1); tg.strokeRoundedRect(-58, -9, 116, 18, 5); + tag.add(tg); + tag.add(this.add.text(0, 0, 'EXILES AT TURN END', { + fontFamily: '"Julius Sans One"', fontSize: '10px', color: '#d9b8f0', + }).setOrigin(0.5)); + this.handLayer.add(tag); + } } } @@ -1341,9 +1357,10 @@ export default class SWDBGGame extends Phaser.Scene { const p = this.gs.players[this.humanSeat]; const def = target.zone === 'base' ? baseDef(p.faction, p.base.id) : cardDef((p.inPlay.find((e) => e.card.uid === target.uid) || p.capitals.find((e) => e.card.uid === target.uid)).card); - if (def.ability?.cost?.exileFromHand) { + if (def.ability?.cost?.exileFromHand || def.ability?.cost?.discardFromHand) { // pick the cost card first - this.showHandPickModal('Choose a card to exile as the cost', (uid) => { + const verb = def.ability.cost.exileFromHand ? 'exile' : 'discard'; + this.showHandPickModal(`Choose a card to ${verb} as the cost`, (uid) => { this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid, costUid: uid }); }); return; @@ -1390,7 +1407,7 @@ export default class SWDBGGame extends Phaser.Scene { case 'chooseBase': actChooseBase(this.gs, d.seat, choice); break; case 'target': actChooseTarget(this.gs, d.seat, choice); break; case 'oppDiscard': actOppDiscard(this.gs, d.seat, choice); break; - case 'spyChoice': actSpyChoice(this.gs, d.seat, choice); break; + case 'oppChoice': actOppChoice(this.gs, d.seat, choice); break; case 'chooseOption': actChooseOption(this.gs, d.seat, choice); break; default: break; } @@ -1436,9 +1453,9 @@ export default class SWDBGGame extends Phaser.Scene { this.setPrompt('Enemy pressure! Choose a card to discard'); break; } - case 'spyChoice': { - this.mode = { type: 'spyChoice', decision: d }; - this.showSpyModal(d); + case 'oppChoice': { + this.mode = { type: 'oppChoice', decision: d }; + this.showOppChoiceModal(d); break; } case 'chooseOption': { @@ -1478,6 +1495,23 @@ export default class SWDBGGame extends Phaser.Scene { case 'revealTopDiscard': this.showPeekModal(d, 'Revealed from the galaxy deck', 'Leave on top', 'Discard it'); break; + case 'dealDamage': + this.setPrompt('Choose where the damage lands'); + this.showDealDamageModal(d); + break; + case 'buyGalaxyDiscard': + this.showGalaxyDiscardModal(d); + break; + case 'takeRowTemp': + this.setPrompt('Choose a galaxy-row card of your faction to add to your hand'); + if (d.optional) this.addActionButton('Skip', () => this.applyDecision(d, null)); + break; + case 'lookTopSwap': + this.showLookTopSwapModal(d); + break; + case 'oppTopdeck': + this.showOppTopdeckModal(d); + break; default: // Fallback: auto-pick the first candidate so nothing soft-locks. this.applyDecision(d, d.candidates[0] || null); @@ -1534,23 +1568,136 @@ export default class SWDBGGame extends Phaser.Scene { }); } - showSpyModal(d) { + showOppChoiceModal(d) { const root = this.modalRoot(); const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; - root.add(this.add.text(cx, cy - 120, 'DUROS SPY', { + root.add(this.add.text(cx, cy - 120, 'ENEMY ULTIMATUM', { fontFamily: 'Righteous', fontSize: '30px', color: C.badHex, }).setOrigin(0.5)); root.add(this.add.text(cx, cy - 70, 'Their agent forces a choice:', { fontFamily: '"Julius Sans One"', fontSize: '19px', color: C.text, }).setOrigin(0.5)); - let y = cy; - if (d.options.includes('discard')) { - this.modalButton(cx, y, 'Discard a card from your hand', () => this.applyDecision(d, 'discard'), { width: 420 }); - y += 60; - } - if (d.options.includes('force')) { - this.modalButton(cx, y, 'Let them gain 1 Force', () => this.applyDecision(d, 'force'), { width: 420, variant: 'ghost' }); + const label = (o) => { + if (o.kind === 'discardSelf') return 'Discard a card from your hand'; + if (o.kind === 'giveForce') return `Let them gain ${o.n || 1} Force`; + if (o.kind === 'attackBoost') return `Let their unit gain ${o.n || 1} attack`; + return o.kind; + }; + d.options.forEach((o, i) => { + this.modalButton(cx, cy + i * 60, label(o), () => this.applyDecision(d, i), { + width: 420, variant: i === 0 ? undefined : 'ghost', + }); + }); + } + + // Y-Wing: pick the enemy base or one of their capital ships to take the hit. + showDealDamageModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 300, 'CHOOSE A TARGET', { + fontFamily: 'Righteous', fontSize: '28px', color: C.badHex, + }).setOrigin(0.5)); + const o = this.gs.players[1 - this.humanSeat]; + const items = d.candidates.map((ref) => { + if (ref.kind === 'oppBase') return { ref, base: true }; + const e = o.capitals.find((x) => x.card.uid === ref.uid); + return e ? { ref, inst: e.card, damage: e.damage } : null; + }).filter(Boolean); + const pitch = Math.min(260, 1400 / Math.max(1, items.length)); + const x0 = cx - ((items.length - 1) * pitch) / 2; + items.forEach((it, i) => { + const x = x0 + i * pitch; + if (it.base) { + this.makeBaseCard(x, 540, o.faction, o.base, 240, 152, { + parent: root, hover: false, showText: false, highlight: true, + onClick: () => this.applyDecision(d, it.ref), + }); + root.add(this.add.text(x, 650, 'enemy base', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted, + }).setOrigin(0.5)); + } else { + this.makeCard(x, 540, it.inst, 190, 264, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, it.ref), + }); + root.add(this.add.text(x, 690, `${it.damage} damage taken`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted, + }).setOrigin(0.5)); + } + }); + } + + // Jawa Scavenger: purchase (pay cost) from the galaxy discard pile. + showGalaxyDiscardModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 240, 'PURCHASE FROM THE GALAXY DISCARD PILE', { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + const cards = d.candidates + .map((ref) => ({ ref, inst: this.gs.galaxy.discard.find((c) => c.uid === ref.uid) })) + .filter((c) => c.inst); + const cols = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(cards.length * 1.6)))); + const cw = 150, chh = 208; + cards.forEach((c, i) => { + const x = cx - ((Math.min(cols, cards.length) - 1) * (cw + 12)) / 2 + (i % cols) * (cw + 12); + const y = 430 + Math.floor(i / cols) * (chh + 14); + this.makeCard(x, y, c.inst, cw, chh, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + }); + if (d.optional) this.modalButton(cx, GAME_HEIGHT - 110, 'Skip', () => this.applyDecision(d, null), { variant: 'ghost', width: 180 }); + } + + // Moff Jerjerrod: peek the galaxy deck's top card; with the Force, may swap + // it with a row card (the row card goes on top of the deck). + showLookTopSwapModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + const rowRefs = d.candidates.filter((c) => c.kind === 'row'); + root.add(this.add.text(cx, 210, 'TOP OF THE GALAXY DECK', { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + if (d.peekId) this.makeCard(cx, 400, { uid: -1, id: d.peekId }, 200, 278, { parent: root, hover: false, showText: true, noHoverPreview: true }); + if (rowRefs.length) { + root.add(this.add.text(cx, 580, 'The Force is with you — swap it with a galaxy-row card?', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.text, + }).setOrigin(0.5)); + const cards = rowRefs + .map((ref) => ({ ref, inst: this.gs.galaxy.row.find((c) => c.uid === ref.uid) })) + .filter((c) => c.inst); + const pitch = Math.min(180, 1400 / Math.max(1, cards.length)); + const x0 = cx - ((cards.length - 1) * pitch) / 2; + cards.forEach((c, i) => { + this.makeCard(x0 + i * pitch, 750, c.inst, 150, 208, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, c.ref), + }); + }); } + this.modalButton(cx, GAME_HEIGHT - 90, 'Leave it on top', () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 300, variant: rowRefs.length ? 'ghost' : undefined }); + } + + // Jyn Erso: the opponent's hand is revealed — pick one card to place on top + // of their deck. + showOppTopdeckModal(d) { + const root = this.modalRoot(); + const cx = GAME_WIDTH / 2; + root.add(this.add.text(cx, 300, "OPPONENT'S HAND — PICK ONE TO TOPDECK", { + fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, 345, 'The chosen card goes on top of their deck', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, + }).setOrigin(0.5)); + const pitch = Math.min(200, 1400 / Math.max(1, d.candidates.length)); + const x0 = cx - ((d.candidates.length - 1) * pitch) / 2; + d.candidates.forEach((ref, i) => { + this.makeCard(x0 + i * pitch, 560, { uid: ref.uid, id: ref.id }, 170, 236, { + parent: root, hover: false, showText: true, + onClick: () => this.applyDecision(d, ref), + }); + }); } showOptionModal(d) { @@ -1568,9 +1715,11 @@ export default class SWDBGGame extends Phaser.Scene { switch (ab.op) { case 'draw': return `Draw ${ab.n || 1} card${(ab.n || 1) > 1 ? 's' : ''}`; case 'oppDiscard': return 'Opponent discards a card'; - case 'gainResources': return `Gain ${ab.n} resources`; + case 'gainResources': return `Gain ${ab.n} resource${(ab.n || 1) > 1 ? 's' : ''}`; case 'gainForce': return `Gain ${ab.n} Force`; + case 'gainAttack': return `Gain ${ab.n} attack this turn`; case 'damageBase': return `Deal ${ab.n} damage to the enemy base`; + case 'repairBase': return `Repair ${ab.n} damage from your base`; default: return ab.op; } } @@ -1740,7 +1889,10 @@ export default class SWDBGGame extends Phaser.Scene { if ((e.type === 'draw' || e.type === 'rowRefill' || e.type === 'play') && e.uid != null) this._pendingCardUids.add(e.uid); if (e.type === 'buy' && e.uid != null) { if (this._rowPos.has(e.uid)) this._rowGhosts.set(e.uid, e.id); - if (e.topdeck) this._pendingDeckUids.add(e.uid); else this._pendingDiscardUids.add(e.uid); + const dest = e.dest || (e.topdeck ? 'deck' : 'discard'); + if (dest === 'deck') this._pendingDeckUids.add(e.uid); + else if (dest === 'hand') this._pendingCardUids.add(e.uid); + else this._pendingDiscardUids.add(e.uid); } if (e.type === 'bounty' && e.uid != null && this._rowPos.has(e.uid)) { this._rowGhosts.set(e.uid, e.id); @@ -1785,10 +1937,16 @@ export default class SWDBGGame extends Phaser.Scene { case 'forceBonus': return 240; case 'reveal': return 700; case 'exile': return 260; - case 'discard': return e.forced ? 340 : 0; + case 'discard': return (e.forced || e.cost) ? 340 : 0; case 'recover': return 300; case 'repair': return 300; case 'rowDiscard': return 340; + case 'handReveal': return e.bySeat === this.humanSeat ? 2000 : 700; + case 'oppTopdeck': return 500; + case 'rowSwap': return 700; + case 'takeRow': return 500; + case 'topdeckArmed': return 400; + case 'attackBoost': return 260; case 'galaxyReshuffle': return 300; case 'draw': return this._dealingInitial ? 300 : 260; case 'rowRefill': return this._dealingInitial ? 320 : 280; @@ -1979,9 +2137,18 @@ export default class SWDBGGame extends Phaser.Scene { const src = this._rowPos.get(e.uid) || this._orpPos || { x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 }; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const bigW = 316, bigH = 444; // 2x the on-board row/outer-rim card size - const dest = e.topdeck - ? (e.seat === this.humanSeat ? { x: 392, y: 911, w: 90, h: 124 } : { x: 570, y: 132, w: 30, h: 42 }) - : (e.seat === this.humanSeat ? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 } : { x: 570, y: 192, w: 30, h: 42 }); + const destKind = e.dest || (e.topdeck ? 'deck' : 'discard'); + let dest, stowFlip = 'back'; + if (destKind === 'deck') { + dest = e.seat === this.humanSeat ? { x: 392, y: 911, w: 90, h: 124 } : { x: 570, y: 132, w: 30, h: 42 }; + } else if (destKind === 'hand') { + // Fang Fighter — the card joins its buyer's hand (face-up for the human, + // into the face-down fan for the AI) + dest = e.seat === this.humanSeat ? { x: GAME_WIDTH / 2, y: 985, w: 148, h: 206 } : { x: 570, y: 72, w: 30, h: 42 }; + if (e.seat === this.humanSeat) stowFlip = null; + } else { + dest = e.seat === this.humanSeat ? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 } : { x: 570, y: 192, w: 30, h: 42 }; + } const land = () => { this._pendingCardUids.delete(e.uid); this._pendingDiscardUids.delete(e.uid); @@ -2004,7 +2171,7 @@ export default class SWDBGGame extends Phaser.Scene { this.dealCardAnimated({ fromX: cx, fromY: cy, fromW: bigW, fromH: bigH, toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h, - startAs: 'face', flipTo: 'back', id: e.id, duration: 380, + startAs: 'face', flipTo: stowFlip, id: e.id, duration: 380, onLand: land, }); }); @@ -2124,8 +2291,48 @@ export default class SWDBGGame extends Phaser.Scene { this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} exiled`, '#b06bd8', 18); break; case 'discard': - if (e.forced) this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, `${this.seatName(e.seat)} discard${e.seat === this.humanSeat ? '' : 's'} ${cardDef(e.id).name}`, '#c7cede', 18); + if (e.forced || e.cost) this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, `${this.seatName(e.seat)} discard${e.seat === this.humanSeat ? '' : 's'} ${cardDef(e.id).name}`, '#c7cede', 18); break; + case 'handReveal': { + if (e.bySeat === this.humanSeat) { + // show the opponent's revealed hand as a face-up fan for a beat + const n = e.ids.length; + const pitch = Math.min(190, 1300 / Math.max(1, n)); + const x0 = GAME_WIDTH / 2 - ((n - 1) * pitch) / 2; + e.ids.forEach((id, i) => { + const card = this.makeCard(x0 + i * pitch, 430, { uid: -1, id }, 170, 236, { parent: this.fxLayer, hover: false, showText: true, noHoverPreview: true }); + card.setAlpha(0); + this.tweens.add({ targets: card, alpha: 1, duration: 160, delay: i * 60 }); + }); + this.popText(GAME_WIDTH / 2, 260, "Opponent's hand revealed", C.goldHex, 22); + this.time.delayedCall(1900, () => this.fxLayer.removeAll(true)); + } else { + this.showBanner('THEY SEE YOUR HAND'); + } + break; + } + case 'oppTopdeck': + this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, + `${cardDef(e.id).name} placed on top of ${this.seatName(e.seat)}'s deck`, '#b06bd8', 18); + break; + case 'rowSwap': { + this.sfx(SFX.CARD_PLACE); + const at = this._rowPos.get(e.inUid); + if (at) this.shakeAt(at.x, at.y); + this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.inId).name} swapped into the row (${cardDef(e.outId).name} back on top)`, '#c7cede', 18); + break; + } + case 'takeRow': + this.popText(GAME_WIDTH / 2, 430, `${cardDef(e.id).name} taken in hand — exiles at end of turn`, '#b06bd8', 18); + break; + case 'topdeckArmed': + this.popText(GAME_WIDTH / 2, e.seat === this.humanSeat ? 900 : 200, 'Next purchase goes on top of the deck', C.goldHex, 18); + break; + case 'attackBoost': { + const at = this._playPos.get(e.uid); + if (at) this.popText(at.x, at.y - 20, `+${e.n} ⚔`, C.statAtkHex, 30); + break; + } case 'recover': this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} returns to hand`, '#3fbf6f', 18); break; diff --git a/src/games/swdbg/SWDBGLogic.js b/src/games/swdbg/SWDBGLogic.js index 8706195..bc31abb 100644 --- a/src/games/swdbg/SWDBGLogic.js +++ b/src/games/swdbg/SWDBGLogic.js @@ -57,6 +57,8 @@ export function newGame({ humanFaction = 'rebel', seed = (Math.random() * 1e9) | baseDeck: bases.filter((b) => !b.starting).map((b) => b.id), lostBases: 0, resources: 0, + topdeckNext: false, // Twi'lek Smuggler: next purchase goes on top of the deck + tempExileUids: [], // Grand Moff Tarkin: cards exiled at end of turn }); } const state = { @@ -97,9 +99,19 @@ export function forcePegged(state, seat) { return state.force * forceSign(p.faction) >= FORCE_MAX; } -function abilityGatePasses(state, seat, ab) { +function abilityGatePasses(state, seat, ab, ctx = {}) { + const p = P(state, seat); if (ab.requiresForce && !forceWith(state, seat)) return false; - if (ab.requiresCapital && P(state, seat).capitals.length === 0) return false; + if (ab.requiresCapital && p.capitals.length === 0) return false; + if (ab.requiresOppCapital && P(state, opp(seat)).capitals.length === 0) return false; + if (ab.requiresTraitInPlay) { + const all = [...p.inPlay, ...p.capitals]; + if (!all.some((e) => (cardDef(e.card).traits || []).some((t) => ab.requiresTraitInPlay.includes(t)))) return false; + } + if (ab.requiresOtherHero) { + // "another Hero unit in play" — a unique unit besides the ability's own card. + if (!p.inPlay.some((e) => e !== ctx.entry && cardDef(e.card).unique && cardDef(e.card).type === 'unit')) return false; + } return true; } @@ -165,13 +177,15 @@ export function entryAttack(state, seat, entry, { vsRow = false } = {}) { 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; + if (ab.op === 'selfBoostPerBaseLost') atk += ab.amount * p.lostBases; } - // traitBoost auras from my other cards in play. + // traitBoost / typeBoost 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; + if (!oab || oab.timing !== 'constant') continue; + if (oab.op === 'traitBoost' && (def.traits || []).includes(oab.trait)) atk += oab.amount; + if (oab.op === 'typeBoost' && def.type === oab.cardType) atk += oab.amount; } return atk; } @@ -232,7 +246,21 @@ function abilityCandidates(state, seat, ab, ctx = {}) { 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 }); + if (ab.scope !== 'oppPlay') { + // "an enemy capital ship in the galaxy row" — opposing faction only. + for (const c of state.galaxy.row) { + const def = cardDef(c); + if (def.type === 'capital' && def.faction !== 'neutral' && def.faction !== p.faction) { + out.push({ kind: 'rowCapital', uid: c.uid }); + } + } + } + return out; + } + case 'dealDamage': { + const out = []; + if (o.base) out.push({ kind: 'oppBase', seat: o.seat }); + for (const e of o.capitals) out.push({ kind: 'capital', seat: o.seat, uid: e.card.uid }); return out; } case 'exileCards': { @@ -242,8 +270,28 @@ function abilityCandidates(state, seat, ab, ctx = {}) { } case 'recoverDiscard': return p.discard - .filter((c) => ab.filter !== 'unique' || cardDef(c).unique) + .filter((c) => { + const def = cardDef(c); + const f = ab.filter; + if (!f) return true; + if (f === 'unique') return def.unique; // legacy string form + if (f.trait && !(def.traits || []).includes(f.trait)) return false; + if (f.heroUnit && !(def.unique && def.type === 'unit')) return false; + return true; + }) .map((c) => ({ kind: 'own', zone: 'discard', uid: c.uid })); + case 'buyGalaxyDiscard': + return state.galaxy.discard + .filter((c) => { + const def = cardDef(c); + if (def.faction !== 'neutral' && def.faction !== p.faction) return false; + return purchaseCost(state, seat, def) <= p.resources; + }) + .map((c) => ({ kind: 'galaxyDiscard', uid: c.uid })); + case 'takeRowTemp': + return state.galaxy.row + .filter((c) => !ab.faction || cardDef(c).faction === ab.faction) + .map((c) => ({ kind: 'row', uid: c.uid })); case 'freePurchase': return state.galaxy.row .filter((c) => { @@ -258,21 +306,37 @@ function abilityCandidates(state, seat, ab, ctx = {}) { } } +// Which oppChoice options are currently live (victim = seat's opponent). +function oppChoiceOptions(state, seat, ab, ctx = {}) { + const o = P(state, opp(seat)); + return (ab.options || []).filter((option) => { + if (option.kind === 'discardSelf') return o.hand.length > 0; + if (option.kind === 'giveForce') return !forcePegged(state, seat); + if (option.kind === 'attackBoost') return !!ctx.entry; + return false; + }); +} + function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {}) { const p = P(state, seat); const o = P(state, opp(seat)); - if (!abilityGatePasses(state, seat, ab)) return false; - if (ab.cost?.exileFromHand) { + if (!abilityGatePasses(state, seat, ab, ctx)) return false; + const handNeeded = (ab.cost?.exileFromHand || 0) + (ab.cost?.discardFromHand || 0); + if (handNeeded) { const spare = costPoolSize != null ? costPoolSize : p.hand.length; - if (spare < ab.cost.exileFromHand) return false; + if (spare < handNeeded) 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 'oppChoice': return oppChoiceOptions(state, seat, ab, ctx).length > 0; case 'lookTop': return state.galaxy.deck.length > 0; + case 'lookTopSwap': return state.galaxy.deck.length > 0; + case 'lookOppHand': return o.hand.length > 0; case 'revealTop': return state.galaxy.deck.length > 0; + case 'topdeckNextPurchase': return !p.topdeckNext; case 'choose': return ab.options.some((sub) => abilityUsable(state, seat, sub, { ctx })); + case 'multi': return ab.effects.some((sub) => abilityUsable(state, seat, sub, { ctx })); case 'draw': case 'gainResources': case 'gainForce': case 'gainAttack': case 'damageBase': return true; default: { @@ -288,7 +352,7 @@ function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {}) // 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) +// oppChoice — the victim picks one option (Duros Spy, B-Wing) // 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 @@ -297,6 +361,10 @@ 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.kind === 'oppChoice') { + q.options = oppChoiceOptions(state, q.bySeat, { options: q.options }, q.ctx || {}); + if (!q.options.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; } @@ -314,7 +382,7 @@ export function pendingDecision(state) { 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 === 'oppChoice') return { kind: 'oppChoice', seat: q.seat, bySeat: q.bySeat, options: q.options.map((o) => ({ ...o })) }; 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 }; } @@ -338,15 +406,15 @@ export function legalActions(state, seat) { for (const c of p.hand) out.plays.push(c.uid); - const tryAbility = (zone, uid, def, usedFlag) => { + const tryAbility = (zone, uid, def, usedFlag, entry = null) => { const ab = def.ability; if (!ab || usedFlag) return; if (ab.timing !== 'action') return; - if (!abilityUsable(state, seat, ab)) return; + if (!abilityUsable(state, seat, ab, { ctx: { entry } })) 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); + for (const e of p.inPlay) tryAbility('play', e.card.uid, cardDef(e.card), e.used, e); + for (const e of p.capitals) tryAbility('capital', e.card.uid, cardDef(e.card), e.used, e); if (p.base) { const b = baseDef(p.faction, p.base.id); if (b.ability?.timing === 'action' && !p.baseUsed && abilityUsable(state, seat, b.ability)) { @@ -385,8 +453,10 @@ export function legalActions(state, seat) { function queueOp(state, seat, ab, ctx = {}) { switch (ab.op) { case 'draw': { + const p = P(state, seat); 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; + if (ab.nBase && p.base?.id === ab.nBase.base) n = ab.nBase.n; + if (ab.nWithCard && [...p.inPlay, ...p.capitals].some((e) => e.card.id === ab.nWithCard.id)) n = ab.nWithCard.n; drawCards(state, seat, n); return; } @@ -433,14 +503,12 @@ function queueOp(state, seat, ab, ctx = {}) { } return; } - case 'spyChoice': { + case 'oppChoice': { const victim = opp(seat); - const options = []; - if (P(state, victim).hand.length) options.push('discard'); - if (!forcePegged(state, seat)) options.push('force'); + const options = oppChoiceOptions(state, seat, ab, ctx); 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 }); + if (options.length === 1) { applyOppChoiceOption(state, seat, victim, options[0], ctx); return; } + state.effectQueue.push({ kind: 'oppChoice', seat: victim, bySeat: seat, options, ctx }); return; } case 'lookTop': { @@ -452,6 +520,30 @@ function queueOp(state, seat, ab, ctx = {}) { }); return; } + case 'lookTopSwap': { + // Moff Jerjerrod: look at the top card; while the Force is with you, you + // may swap it with a galaxy-row card ('keep' = leave everything as-is). + const top = state.galaxy.deck[state.galaxy.deck.length - 1]; + if (!top) return; + const candidates = [{ kind: 'opt', v: 'keep' }]; + if (forceWith(state, seat)) { + for (const c of state.galaxy.row) candidates.push({ kind: 'row', uid: c.uid }); + } + state.effectQueue.push({ kind: 'target', seat, op: 'lookTopSwap', peekId: top.id, optional: false, candidates }); + return; + } + case 'lookOppHand': { + const o = P(state, opp(seat)); + if (!o.hand.length) return; + emit(state, { type: 'handReveal', seat: o.seat, bySeat: seat, ids: o.hand.map((c) => c.id) }); + if (ab.forceTopdeck && forceWith(state, seat)) { + state.effectQueue.push({ + kind: 'target', seat, op: 'oppTopdeck', optional: false, + candidates: o.hand.map((c) => ({ kind: 'oppHand', uid: c.uid, id: c.id })), + }); + } + return; + } case 'revealTop': { const top = state.galaxy.deck[state.galaxy.deck.length - 1]; if (!top) return; @@ -459,8 +551,7 @@ function queueOp(state, seat, ab, ctx = {}) { 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 }); + queueOp(state, seat, ab.onMine || { op: 'gainResources', n: 1 }, ctx); } else if (def.faction !== 'neutral') { state.effectQueue.push({ kind: 'target', seat, op: 'revealTopDiscard', peekId: top.id, optional: false, @@ -469,8 +560,14 @@ function queueOp(state, seat, ab, ctx = {}) { } return; } + case 'topdeckNextPurchase': { + P(state, seat).topdeckNext = true; + emit(state, { type: 'topdeckArmed', seat }); + return; + } case 'exileCards': { - for (let i = 0; i < (ab.n || 1); i++) { + const n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : (ab.n || 1); + for (let i = 0; i < n; i++) { state.effectQueue.push({ kind: 'target', seat, op: 'exileCards', optional: ab.optional !== false, dynamic: 'exileCards', @@ -480,17 +577,28 @@ function queueOp(state, seat, ab, ctx = {}) { return; } case 'choose': { - const usable = ab.options.filter((sub) => abilityUsable(state, seat, sub)); + const usable = ab.options.filter((sub) => abilityUsable(state, seat, sub, { ctx })); 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': { + case 'multi': { + for (const sub of ab.effects) { + if (sub.op === 'purchaseToHand') continue; // resolved by placePurchased + if (!abilityGatePasses(state, seat, sub, ctx)) continue; + queueOp(state, seat, sub, ctx); + } + return; + } + case 'purchaseToHand': + return; // resolved by placePurchased at buy time + case 'discardRow': case 'destroyCapital': case 'recoverDiscard': case 'freePurchase': + case 'dealDamage': case 'buyGalaxyDiscard': case 'takeRowTemp': { const candidates = abilityCandidates(state, seat, ab); if (!candidates.length) return; state.effectQueue.push({ - kind: 'target', seat, op: ab.op, ab, optional: ab.op === 'discardRow', + kind: 'target', seat, op: ab.op, ab, optional: ab.op === 'discardRow' || !!ab.optional, candidates, }); return; @@ -500,6 +608,26 @@ function queueOp(state, seat, ab, ctx = {}) { } } +// Resolve one oppChoice option the victim picked (or the only one available). +function applyOppChoiceOption(state, bySeat, victimSeat, option, ctx = {}) { + switch (option.kind) { + case 'discardSelf': + if (P(state, victimSeat).hand.length) state.effectQueue.unshift({ kind: 'oppDiscard', seat: victimSeat }); + return; + case 'giveForce': + moveForce(state, bySeat, option.n || 1); + return; + case 'attackBoost': + if (ctx.entry) { + ctx.entry.tempAttack += option.n || 1; + emit(state, { type: 'attackBoost', seat: bySeat, uid: ctx.entry.card.uid, n: option.n || 1 }); + } + return; + default: + throw new Error(`unknown oppChoice option ${option.kind}`); + } +} + // Direct base damage (card abilities): ignores capital ships. function dealBaseDamage(state, seat, n, { direct = false } = {}) { const o = P(state, opp(seat)); @@ -521,14 +649,30 @@ function checkBaseDestroyed(state, attackerSeat) { 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]) { +// "When X defeats a target in the galaxy row" — only the units that actually +// joined the winning attack trigger their onBounty abilities. +function fireOnBounty(state, seat, participants) { + for (const e of participants) { const ab = cardDef(e.card).ability; - if (ab && ab.timing === 'onBounty' && abilityGatePasses(state, seat, ab)) queueOp(state, seat, ab, { entry: e }); + if (ab && ab.timing === 'onBounty' && abilityGatePasses(state, seat, ab, { entry: e })) queueOp(state, seat, ab, { entry: e }); } } +// Damage one of a player's capital ships in play; destroys it at hp. +function damageCapital(state, ownerSeat, entry, n) { + const o = P(state, ownerSeat); + const hp = cardDef(entry.card).hp; + const dealt = Math.min(n, hp - entry.damage); + entry.damage += dealt; + emit(state, { type: 'capitalDamage', seat: ownerSeat, uid: entry.card.uid, n: dealt, hp, damage: entry.damage }); + if (entry.damage >= hp) { + o.capitals.splice(o.capitals.indexOf(entry), 1); + o.discard.push(entry.card); + emit(state, { type: 'capitalDestroyed', seat: ownerSeat, id: entry.card.id, uid: entry.card.uid }); + } + return dealt; +} + // ── Mutators ──────────────────────────────────────────────────────────────── export function actTurn(state, seat, action) { assertDecision(state, seat, 'turn'); @@ -571,7 +715,7 @@ function playCard(state, seat, card) { emit(state, { type: 'attackBoost', seat, uid: card.uid, n: b.ability.amount, from: 'base' }); } } - if (def.ability?.timing === 'onPlay' && abilityGatePasses(state, seat, def.ability)) queueOp(state, seat, def.ability, { entry }); + if (def.ability?.timing === 'onPlay' && abilityGatePasses(state, seat, def.ability, { entry })) queueOp(state, seat, def.ability, { entry }); } function useAbility(state, seat, action) { @@ -588,7 +732,7 @@ function useAbility(state, seat, action) { 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'); + if (!abilityUsable(state, seat, ab, { ctx: { entry } })) throw new Error('ability not usable now'); // Pay costs first (rulebook: exileSelf cards keep their printed values — // those were already collected at play time). @@ -599,6 +743,13 @@ function useAbility(state, seat, action) { state.exile.push(c); emit(state, { type: 'exile', seat, id: c.id, from: 'hand', cost: true }); } + if (ab.cost?.discardFromHand) { + const c = p.hand.find((x) => x.uid === action.costUid); + if (!c) throw new Error('discardFromHand cost needs a hand card (costUid)'); + p.hand.splice(p.hand.indexOf(c), 1); + p.discard.push(c); + emit(state, { type: 'discard', seat, id: c.id, cost: true }); + } if (ab.cost?.exileSelf && entry) { const pool = cardDef(entry.card).type === 'capital' ? p.capitals : p.inPlay; pool.splice(pool.indexOf(entry), 1); @@ -630,8 +781,29 @@ function buyCard(state, seat, uid) { p.resources -= cost; removeRowCard(state, uid); } - p.discard.push(card); - emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: def.cost }); + placePurchased(state, seat, card, def); +} + +// Route a just-purchased card to its destination (discard by default; deck top +// while a Twi'lek topdeck is armed; hand via purchaseToHand) and fire the +// card's onPurchase effects. +function placePurchased(state, seat, card, def) { + const p = P(state, seat); + let dest = 'discard'; + if (p.topdeckNext) { dest = 'deck'; p.topdeckNext = false; } + const ab = def.ability; + if (ab?.timing === 'onPurchase') { + const effects = ab.op === 'multi' ? ab.effects : [ab]; + for (const sub of effects) { + if (!abilityGatePasses(state, seat, sub)) continue; + if (sub.op === 'purchaseToHand') { dest = 'hand'; continue; } + queueOp(state, seat, sub, {}); + } + } + if (dest === 'deck') p.deck.push(card); + else if (dest === 'hand') p.hand.push(card); + else p.discard.push(card); + emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: def.cost, dest }); } function attackRow(state, seat, action) { @@ -659,7 +831,9 @@ function attackRow(state, seat, action) { 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); + if (r.exile) queueOp(state, seat, { op: 'exileCards', n: r.exile, optional: true }); + if (r.freePurchase) queueOp(state, seat, { op: 'freePurchase', faction: p.faction, optional: true }); + fireOnBounty(state, seat, entries); } function attackBase(state, seat, action) { @@ -686,16 +860,7 @@ function attackBase(state, seat, action) { 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 }); - } + pool -= damageCapital(state, o.seat, e, pool); } if (pool > 0 && o.capitals.length === 0 && o.base) { o.base.damage += pool; @@ -706,16 +871,31 @@ function attackBase(state, seat, action) { function endTurn(state, seat) { const p = P(state, seat); + // Cards borrowed via takeRowTemp (Grand Moff Tarkin) are exiled instead of + // discarded, from whichever zone they ended up in (hand, in play, capitals). + const tempExile = (card) => { + if (!p.tempExileUids.includes(card.uid)) return false; + state.exile.push(card); + emit(state, { type: 'exile', seat, id: card.id, from: 'tempExile' }); + return true; + }; for (const e of p.inPlay) { + if (tempExile(e.card)) continue; p.discard.push(e.card); emit(state, { type: 'endTurnDiscard', seat, uid: e.card.uid, id: e.card.id, zone: 'inPlay' }); } p.inPlay = []; for (const c of p.hand) { + if (tempExile(c)) continue; p.discard.push(c); emit(state, { type: 'endTurnDiscard', seat, uid: c.uid, id: c.id, zone: 'hand' }); } p.hand = []; + for (const e of p.capitals.slice()) { + if (tempExile(e.card)) p.capitals.splice(p.capitals.indexOf(e), 1); + } + p.tempExileUids = []; + p.topdeckNext = false; p.resources = 0; drawCards(state, seat, state.meta.handSize); emit(state, { type: 'turnEnd', seat }); @@ -802,6 +982,58 @@ function applyTarget(state, seat, q, ref) { emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: 0, free: true, topdeck: !!topdeck }); return; } + case 'buyGalaxyDiscard': { + const i = state.galaxy.discard.findIndex((c) => c.uid === ref.uid); + if (i < 0) return; + const [card] = state.galaxy.discard.splice(i, 1); + const def = cardDef(card); + const cost = purchaseCost(state, seat, def); + if (p.resources < cost) { state.galaxy.discard.splice(i, 0, card); return; } + p.resources -= cost; + placePurchased(state, seat, card, def); + return; + } + case 'takeRowTemp': { + const card = removeRowCard(state, ref.uid); + if (!card) return; + p.hand.push(card); + p.tempExileUids.push(card.uid); + emit(state, { type: 'takeRow', seat, id: card.id, uid: card.uid, tempExile: true }); + return; + } + case 'dealDamage': { + const n = q.ab?.n || 1; + if (ref.kind === 'oppBase') { + dealBaseDamage(state, seat, n, { direct: true }); + } else { + const o = P(state, ref.seat); + const e = o.capitals.find((x) => x.card.uid === ref.uid); + if (e) damageCapital(state, o.seat, e, n); + } + return; + } + case 'oppTopdeck': { + const o = P(state, opp(seat)); + const i = o.hand.findIndex((c) => c.uid === ref.uid); + if (i < 0) return; + const [c] = o.hand.splice(i, 1); + o.deck.push(c); + emit(state, { type: 'oppTopdeck', seat: o.seat, bySeat: seat, id: c.id }); + return; + } + case 'lookTopSwap': { + if (ref.kind === 'row') { + const i = state.galaxy.row.findIndex((c) => c.uid === ref.uid); + const top = state.galaxy.deck.pop(); + if (i < 0 || !top) { if (top) state.galaxy.deck.push(top); return; } + const [rowC] = state.galaxy.row.splice(i, 1, top); + state.galaxy.deck.push(rowC); + emit(state, { type: 'rowSwap', seat, inId: top.id, inUid: top.uid, outId: rowC.id, outUid: rowC.uid, slot: i }); + } else { + emit(state, { type: 'galaxyTopKept', seat }); + } + return; + } case 'lookTop': { if (ref.v === 'discard') { const c = state.galaxy.deck.pop(); @@ -836,16 +1068,11 @@ export function actOppDiscard(state, seat, uid) { 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}`); +export function actOppChoice(state, seat, idx) { + const d = assertDecision(state, seat, 'oppChoice'); + if (idx == null || idx < 0 || idx >= d.options.length) throw new Error('bad oppChoice index'); 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 }); - } + applyOppChoiceOption(state, q.bySeat, seat, q.options[idx], q.ctx || {}); } export function actChooseOption(state, seat, idx) { diff --git a/src/games/swdbg/sprites.md b/src/games/swdbg/sprites.md index 8dc6d64..a8580aa 100644 --- a/src/games/swdbg/sprites.md +++ b/src/games/swdbg/sprites.md @@ -2,36 +2,59 @@ 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. +**no** art. This document lists the sprite sheets that 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. +whether each sheet's texture is loaded **and whether the mapped frame actually +exists in the PNG** — a card mapped to a frame beyond the sheet's last row +simply stays procedural until you extend the image, so you can paint the new +frames at your own pace. 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 +Hero-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. --- +## ⚠ 2026-07 card overhaul — what changed for the art + +The card list was regenerated from the owner-transcribed CSV +(`assets/info/sw-force-cards.csv`). **Every previously painted frame keeps its +position** (append-only policy). What you need to know: + +1. **The sheet grows one row group: 2400 × 1085 → 2400 × 1395** (8 columns × + 9 rows, 155 px per row). Extend the canvas downward; nothing above moves. +2. **11 new cards need art in frames 55–65** (frame 55 is the old spare + top-sheet cell; 56–65 live in the two new rows; 66–71 stay spare). +3. **4 frames are orphaned but preserved** — their cards left the game, the + art stays in place in case they return: 18 (Emperor Palpatine), + 22 (Rebel Pathfinder), 29 (K-2SO), 38 (Medical Frigate). Nothing reads them. +4. **Frame 54 (Khetanna / Jabba's Sail Barge) is reused as-is** by the new + `jabbassailbarge` card — the barge is now a neutral 7-cost *unit* instead + of a capital ship. Same art, new stats; no repaint needed. +5. **Faction/type changes on existing frames** (procedural frame recolors + automatically — repaint only if the art itself feels off-faction): + - Boba Fett (51): neutral → **Empire** + - Blockade Runner (36): Rebel → **neutral** + - Millennium Falcon (40): capital ship → **unit** (same art window) + +--- + ## 1. Card art sheet — `swdbg-cards-art.png` | | | |---|---| | **Path** | `public/assets/images/swdbg-cards-art.png` | | **Frame size** | **300 × 155 px** (aspect ≈ 1.9 : 1, landscape — matches the card art window) | -| **Sheet size** | **8 columns × 7 rows = 2400 × 1085 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) | +| **Sheet size** | **8 columns × 9 rows = 2400 × 1395 px** — 66 frames used (0–65), 66–71 spare | +| **Status** | ✅ frames 0–54 painted (existing sheet) · ⛔ frames 55–65 needed | +| **JSON** | `cardSheet` (path set) + `cards` map (already updated for all 66 ids) | The art window sits just below the name plate on every unit/ship/character card. **The overall card is roughly a standard trading-card shape, but the @@ -46,15 +69,16 @@ bust, since a portrait-oriented image will letterbox heavily in this window. ### Frame map Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim. +**Hero** cards get the procedural gold inset. | 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 | | +| 0 | `allianceshuttle` | Alliance Shuttle | R | starter | +| 1 | `rebeltrooper` | Rebel Trooper | R | starter | +| 2 | `templeguardian` | Temple Guardian | R | starter | +| 3 | `imperialshuttle` | Imperial Shuttle | E | starter | +| 4 | `stormtrooper` | Stormtrooper | E | starter | +| 5 | `inquisitor` | Inquisitor | E | starter | | 6 | `outerrimpilot` | Outer Rim Pilot | N | shared neutral card | | 7 | `tiefighter` | TIE Fighter | E | | | 8 | `scouttrooper` | Scout Trooper | E | | @@ -63,33 +87,33 @@ Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim. | 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 | | +| 14 | `generalveers` | General Veers | E | Hero | +| 15 | `grandmofftarkin` | Grand Moff Tarkin | E | Hero | +| 16 | `directorkrennic` | Director Krennic | E | Hero | +| 17 | `darthvader` | Darth Vader | E | Hero | +| 18 | — | *(orphaned: Emperor Palpatine)* | | card removed; art preserved, unused | +| 19 | `gozanticruiser` | Gozanti Cruiser | E | capital | +| 20 | `imperialcarrier` | Imperial Carrier | E | capital | +| 21 | `stardestroyer` | Star Destroyer | E | capital | +| 22 | — | *(orphaned: Rebel Pathfinder)* | | card removed; art preserved, unused | | 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 | +| 28 | `cassianandor` | Cassian Andor | R | Hero | +| 29 | — | *(orphaned: K-2SO)* | | card removed; art preserved, unused | +| 30 | `chirrutimwe` | Chirrut Îmwe | R | no longer a Hero (still 1-of) | +| 31 | `jynerso` | Jyn Erso | R | Hero | +| 32 | `chewbacca` | Chewbacca | R | Hero | +| 33 | `hansolo` | Han Solo | R | Hero | +| 34 | `princessleia` | Princess Leia | R | Hero | +| 35 | `lukeskywalker` | Luke Skywalker | R | Hero | +| 36 | `blockaderunner` | Blockade Runner | **N** | capital — faction changed from Rebel | +| 37 | `hammerheadcorvette` | Hammerhead Corvette | R | capital | +| 38 | — | *(orphaned: Medical Frigate)* | | card removed; art preserved, unused | +| 39 | `moncalamaricruiser` | Mon Calamari Cruiser | R | capital | +| 40 | `millenniumfalcon` | Millennium Falcon | R | Hero — now a **unit**, not a capital | | 41 | `z95headhunter` | Z-95 Headhunter | N | | | 42 | `jawascavenger` | Jawa Scavenger | N | | | 43 | `keldormystic` | Kel Dor Mystic | N | | @@ -98,27 +122,48 @@ Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim. | 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 | +| 49 | `bossk` | Bossk | N | Hero | +| 50 | `ig88` | IG-88 | N | Hero | +| 51 | `bobafett` | Boba Fett | **E** | Hero — faction changed from neutral | +| 52 | `jabbathehutt` | Jabba the Hutt | N | Hero | +| 53 | `crocruiser` | C-ROC Cruiser | N | capital | +| 54 | `jabbassailbarge` | Jabba's Sail Barge | N | Hero — reuses the Khetanna art; now a unit | +| **55** | `lobot` | **Lobot** | N | **NEW ART** — Hero; old spare cell (row 6, col 7) | +| **56** | `dengar` | **Dengar** | N | **NEW ART** — Hero bounty hunter | +| **57** | `nebulonbfrigate` | **Nebulon-B Frigate** | N | **NEW ART** — capital, medical/escort frigate | +| **58** | `landocalrissian` | **Lando Calrissian** | N | **NEW ART** — Hero | +| **59** | `bazemalbus` | **Baze Malbus** | R | **NEW ART** — Hero, heavy repeater gunner | +| **60** | `rebeltransport` | **Rebel Transport** | R | **NEW ART** — capital, GR-75 | +| **61** | `uwing` | **U-Wing** | R | **NEW ART** — troop dropship | +| **62** | `bwing` | **B-Wing** | R | **NEW ART** — heavy assault fighter | +| **63** | `admiralpiett` | **Admiral Piett** | E | **NEW ART** — Hero officer | +| **64** | `moffjerjerrod` | **Moff Jerjerrod** | E | **NEW ART** — Hero officer | +| **65** | `landingcraft` | **Landing Craft** | E | **NEW ART** — Sentinel-class shuttle | +| 66–71 | — | spare | | leave blank for future cards | -> 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. +New-frame grid positions (300 × 155 px cells): + +| Frame | Row | Col | Pixel box (x, y) | +|---:|---:|---:|---| +| 55 | 6 | 7 | 2100–2400, 930–1085 | +| 56–63 | 7 | 0–7 | 0–2400, 1085–1240 | +| 64, 65 | 8 | 0, 1 | 0–600, 1240–1395 | + +> Priority for the new frames: the Heroes (55, 56, 58, 59, 63, 64) show the +> gold inset and get inspected most; the ships (57, 60, 61, 62, 65) read fine +> procedurally a while longer. --- -## 2. Base art sheet — `swdbg-bases-art.png` +## 2. Base art sheet — `swdbg-bases-art.png` *(unchanged)* | | | |---|---| | **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) | +| **Status** | ✅ painted — no changes needed (bases were not part of the card overhaul) | +| **JSON** | `baseSheet` + `bases` map (unchanged) | 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 @@ -143,7 +188,7 @@ letterboxed). Also shown up to 560×340 in the win/lose reveal. --- -## 3. Menu icon — `game-icons.png` frame **78** *(shared sheet)* +## 3. Menu icon — `game-icons.png` frame **78** *(shared sheet, unchanged)* | | | |---|---| @@ -163,14 +208,22 @@ goes into an **existing** sheet rather than a new file. ## Quick checklist -- [ ] `swdbg-cards-art.png` — 2400×1085 (8×7 grid), 300×155 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. +- [ ] `swdbg-cards-art.png` — **extend the canvas from 2400×1085 to + 2400×1395** (add 310 px at the bottom; existing art untouched). +- [ ] Paint the 11 new frames: + - [ ] 55 Lobot (old spare cell, row 6 col 7) + - [ ] 56 Dengar · 57 Nebulon-B Frigate · 58 Lando Calrissian · + 59 Baze Malbus · 60 Rebel Transport · 61 U-Wing · 62 B-Wing · + 63 Admiral Piett (row 7) + - [ ] 64 Moff Jerjerrod · 65 Landing Craft (row 8) +- [ ] *(optional)* Revisit frame 51 (Boba Fett — now Empire-framed) and + frame 36 (Blockade Runner — now neutral-framed) if the palettes clash + with their new faction edge colors. +- [x] Frame 54 — no repaint; the Khetanna art now serves Jabba's Sail Barge. +- [x] `swdbg-bases-art.png` — unchanged. +- [ ] `game-icons.png` frame 78 — 44×44 menu icon (still outstanding). -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. +The frame→id mappings are already updated in `public/data/swdbg-artwork.json` +— the new cards render procedurally today and pick up your art automatically +as soon as the extended PNG contains their frames (no code or JSON changes +needed when you repaint). diff --git a/tools/verifySWDBG.js b/tools/verifySWDBG.js index 7dbd4b7..81d5db6 100644 --- a/tools/verifySWDBG.js +++ b/tools/verifySWDBG.js @@ -1,7 +1,9 @@ // 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. +// from data/swdbg-cards.json, the same file the game loads at runtime, and is +// cross-checked against the owner's transcription of the physical cards in +// assets/info/sw-force-cards.csv. import { readFileSync } from 'node:fs'; import { @@ -10,7 +12,7 @@ import { } from '../src/games/swdbg/SWDBGData.js'; import { newGame, makeRng, pendingDecision, takeEvents, publicView, legalActions, - actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, + actTurn, actChooseBase, actChooseTarget, actOppDiscard, actOppChoice, actChooseOption, entryAttack, forceWith, forcePegged, purchaseCost, isOver, FORCE_MAX, } from '../src/games/swdbg/SWDBGLogic.js'; import { decide } from '../src/games/swdbg/SWDBGAI.js'; @@ -27,6 +29,20 @@ function ok(cond, msg) { return false; } +const COST_KEYS = ['exileSelf', 'exileFromHand', 'discardFromHand']; +const GATE_KEYS = ['requiresForce', 'requiresCapital', 'requiresOppCapital', 'requiresTraitInPlay', 'requiresOtherHero']; +const OPPCHOICE_KINDS = ['discardSelf', 'giveForce', 'attackBoost']; + +// Every op reachable inside an ability (choose options, multi effects, revealTop onMine). +function opsIn(ab) { + if (!ab) return []; + let out = [ab.op]; + if (ab.op === 'choose') for (const o of ab.options || []) out = out.concat(opsIn(o)); + if (ab.op === 'multi') for (const o of ab.effects || []) out = out.concat(opsIn(o)); + if (ab.op === 'revealTop' && ab.onMine) out = out.concat(opsIn(ab.onMine)); + return out; +} + // ── 1. Content sanity ──────────────────────────────────────────────────────── console.log('Content sanity…'); { @@ -35,10 +51,12 @@ console.log('Content sanity…'); 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`); + for (const op of opsIn(ab)) 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`); + if (ab.cost) ok(Object.keys(ab.cost).every((k) => COST_KEYS.includes(k)), `${id}: cost keys valid`); + ok(Object.keys(ab).filter((k) => k.startsWith('requires')).every((k) => GATE_KEYS.includes(k)), `${id}: gate keys valid`); + if (ab.op === 'oppChoice') ok((ab.options || []).every((o) => OPPCHOICE_KINDS.includes(o.kind)), `${id}: oppChoice kinds valid`); }; for (const def of json.cards) { total += def.count; @@ -82,7 +100,90 @@ console.log('Content sanity…'); ok(json.meta.basesToWin === 3 && json.meta.handSize === 5 && json.meta.galaxyRowSize === 6, 'meta matches the beginner rules'); } -// ── 2. Setup invariants ────────────────────────────────────────────────────── +// ── 2. CSV cross-check (owner's transcription of the physical cards) ──────── +console.log('CSV cross-check…'); +{ + const csvText = readFileSync(new URL('../assets/info/sw-force-cards.csv', import.meta.url), 'utf8'); + const rows = []; + { + let row = [], field = '', inQ = false; + for (let i = 0; i < csvText.length; i++) { + const ch = csvText[i]; + if (inQ) { + if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQ = false; } + else field += ch; + } else if (ch === '"') inQ = true; + else if (ch === ',') { row.push(field); field = ''; } + else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && csvText[i + 1] === '\n') i++; + row.push(field); field = ''; + if (row.some((c) => c.trim() !== '')) rows.push(row); + row = []; + } else field += ch; + } + row.push(field); + if (row.some((c) => c.trim() !== '')) rows.push(row); + } + const header = rows.shift().map((h) => h.trim()); + ok(header[0] === 'Title' && header.length === 14, `CSV header shape (${header.length} cols)`); + + const norm = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const ALIASES = { gonzanticruiser: 'gozanticruiser' }; // CSV typo for Gozanti + const byNorm = new Map(); + for (const def of json.cards) byNorm.set(norm(def.name), def); + + const factionMap = { neutral: 'neutral', rebelalliance: 'rebel', empire: 'empire' }; + const parseReward = (s) => { + const t = (s || '').trim(); + if (!t) return null; + if (/^purchase/i.test(t)) return { freePurchase: true }; + if (/^exile/i.test(t)) { const m = t.match(/(\d+)/); return { exile: m ? +m[1] : 1 }; } + const out = {}; + const res = t.match(/(\d+)\s*Resources?/i); if (res) out.resources = +res[1]; + const force = t.match(/(\d+)\s*Force/i); if (force) out.force = +force[1]; + return Object.keys(out).length ? out : null; + }; + const normReward = (r) => { + if (!r) return 'null'; + const o = {}; + for (const k of ['resources', 'force', 'draw', 'exile', 'freePurchase']) if (r[k]) o[k] = r[k]; + return JSON.stringify(o); + }; + + const seen = new Set(); + for (const r of rows) { + let [title, cost, attack, resources, force, type, trait, ability, bounty, bountyReward, count, hp, aligned, hero] = r.map((c) => c.trim()); + // Known transcription quirk: the Imperial Carrier row is shifted one cell — + // its ability text sits in the Trait column. + if (!ability && trait.length > 30) { ability = trait; trait = ''; } + const key = ALIASES[norm(title)] || norm(title); + const def = byNorm.get(key) || json.cards.find((c) => c.id === key); + if (!ok(!!def, `CSV "${title}" exists in swdbg-cards.json`)) continue; + seen.add(def.id); + const num = (v) => (v === '' ? 0 : +v); + ok(def.cost === num(cost), `${def.id}: cost ${def.cost} matches CSV ${cost}`); + ok((def.attack || 0) === num(attack), `${def.id}: attack matches CSV`); + ok((def.resources || 0) === num(resources), `${def.id}: resources matches CSV`); + ok((def.force || 0) === num(force), `${def.id}: force matches CSV`); + ok(def.type === (type === 'Capital Ship' ? 'capital' : 'unit'), `${def.id}: type matches CSV`); + ok(def.count === num(count), `${def.id}: count ${def.count} matches CSV ${count}`); + ok((def.hp ?? null) === (hp === '' ? null : +hp), `${def.id}: hp matches CSV`); + ok((def.target ?? null) === (bounty === '' ? null : +bounty), `${def.id}: bounty value matches CSV`); + ok(def.faction === factionMap[norm(aligned)], `${def.id}: faction matches CSV`); + ok(!!def.unique === (hero.toLowerCase() === 'yes'), `${def.id}: hero flag matches CSV`); + ok(normReward(def.reward) === normReward(parseReward(bountyReward)), `${def.id}: bounty reward matches CSV (${bountyReward || 'none'})`); + ok((ability === '') === !def.ability, `${def.id}: ability presence matches CSV`); + if (def.type === 'unit' && trait !== '' && ability !== trait) { + const want = trait.split(',').map((t) => t.trim()).sort().join('|'); + const have = (def.traits || []).slice().sort().join('|'); + ok(want === have, `${def.id}: traits [${have}] match CSV [${want}]`); + } + } + ok(seen.size === json.cards.length, `every JSON galaxy card appears in the CSV (${seen.size}/${json.cards.length})`); + ok(rows.length === json.cards.length, `CSV row count matches card count (${rows.length})`); +} + +// ── 3. Setup invariants ────────────────────────────────────────────────────── console.log('Setup invariants…'); { for (const faction of FACTIONS) { @@ -117,7 +218,7 @@ function driveDecision(st, d, skill, rnd, tally = null) { 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 'oppChoice': actOppChoice(st, d.seat, choice); break; case 'chooseOption': actChooseOption(st, d.seat, choice); break; default: throw new Error(`unknown decision kind ${d.kind}`); } @@ -167,7 +268,7 @@ function playGame(seed, skills, collect = null) { return { st, tally, steps }; } -// ── 3. Determinism ─────────────────────────────────────────────────────────── +// ── 4. Determinism ─────────────────────────────────────────────────────────── console.log('Determinism…'); { const logA = []; @@ -178,7 +279,7 @@ console.log('Determinism…'); `same seed reproduces the same event log (${logA.length} events)`); } -// ── 4. Self-play soak ──────────────────────────────────────────────────────── +// ── 5. Self-play soak ──────────────────────────────────────────────────────── console.log('Self-play soak (300 games)…'); { let games = 0; @@ -225,21 +326,28 @@ console.log('Skill gradient (80 games, 5 vs 1)…'); ok(strongWins >= 52, `skill 5 dominates skill 1 (${strongWins}/80)`); } -// ── 5. Scripted rule spot-checks ───────────────────────────────────────────── +// ── 6. 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); } +let giveUid = 900000; 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 inst = { uid: ++giveUid, 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; } +function plantRow(st, slot, id) { + const inst = { uid: ++giveUid, id }; + st.galaxy.deck.push(st.galaxy.row[slot]); // keep total card count honest + st.galaxy.row[slot] = inst; + return inst; +} { // Faction purchase restriction: Empire cannot buy Rebel cards, anyone buys neutral. @@ -283,24 +391,35 @@ function give(st, seat, id) { } { - // Direct ability damage ignores capital ships; base destruction wins at 3. + // Y-Wing dealDamage: exile the unit, then damage the base OR a capital. 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'); + const reb = seatOf(st, 'rebel'); + const emp = 1 - reb; + st.turnSeat = reb; + const cap = give(st, emp, 'gozanticruiser'); // hp 3 + const y1 = give(st, reb, 'ywing'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: y1.card.uid }); + ok(st.exile.some((c) => c.uid === y1.card.uid), 'Y-Wing exiles itself as the cost'); + let d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'dealDamage', 'Y-Wing queues a damage target choice'); + ok(d.candidates.some((c) => c.kind === 'oppBase') && d.candidates.some((c) => c.kind === 'capital'), + 'both the enemy base and their capital are candidates'); + actChooseTarget(st, reb, d.candidates.find((c) => c.kind === 'capital')); + ok(cap.damage === 2, 'capital takes 2 damage and survives'); + const y2 = give(st, reb, 'ywing'); + const dmgBefore = st.players[emp].base.damage; + actTurn(st, reb, { type: 'ability', zone: 'play', uid: y2.card.uid }); + d = pendingDecision(st); + actChooseTarget(st, reb, d.candidates.find((c) => c.kind === 'oppBase')); + ok(st.players[emp].base.damage === dmgBefore + 2, 'base takes 2 damage when chosen'); - 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'); + // Base destruction wins at 3 lost bases. + st.players[emp].base.damage = baseDef('empire', st.players[emp].base.id).hp - 1; + st.players[emp].lostBases = 2; + st.players[emp].capitals = []; + const trooper = give(st, reb, 'rebeltrooper'); + actTurn(st, reb, { type: 'attackBase', uids: [trooper.card.uid] }); + ok(st.over && st.winner === reb, 'third base destroyed ends the game immediately'); } { @@ -308,9 +427,7 @@ function give(st, seat, id) { 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 target = plantRow(st, 0, 'landingcraft'); // target 4, reward 4 resources 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; } @@ -319,7 +436,7 @@ function give(st, seat, id) { 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.players[reb].resources === resBefore + 4, 'bounty reward granted'); ok(st.galaxy.row.length === 6, 'row refilled after the bounty'); ok(a1.committed && a2.committed, 'bounty attackers are committed'); } @@ -337,6 +454,28 @@ function give(st, seat, id) { ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row'); } +{ + // Admiral Piett: typeBoost gives every friendly capital ship +1 attack. + const st = freshGame(506); + const emp = seatOf(st, 'empire'); + const sd = give(st, emp, 'stardestroyer'); // 4 attack + ok(entryAttack(st, emp, sd) === 4, 'Star Destroyer base attack is 4'); + give(st, emp, 'admiralpiett'); + ok(entryAttack(st, emp, sd) === 5, 'Piett boosts capital ships to 5'); + const tie = give(st, emp, 'tiefighter'); + ok(entryAttack(st, emp, tie) === 2, 'Piett does not boost units'); +} + +{ + // Baze Malbus: +1 attack per base you have lost. + const st = freshGame(507); + const reb = seatOf(st, 'rebel'); + const baze = give(st, reb, 'bazemalbus'); + ok(entryAttack(st, reb, baze) === 2, 'Baze starts at 2 attack'); + st.players[reb].lostBases = 2; + ok(entryAttack(st, reb, baze) === 4, 'Baze gains 1 attack per lost base'); +} + { // TIE Fighter: action ability gated on having a capital ship in play. const st = freshGame(508); @@ -352,14 +491,73 @@ function give(st, seat, id) { } { - // Cassian Andor: onBounty trigger forces an opponent discard. + // Z-95 Headhunter: gated on the OPPONENT having a capital ship in play. + const st = freshGame(510); + const reb = seatOf(st, 'rebel'); + const emp = 1 - reb; + st.turnSeat = reb; + const z = give(st, reb, 'z95headhunter'); + give(st, reb, 'moncalamaricruiser'); // own capital must NOT satisfy the gate + let threw = false; + try { actTurn(st, reb, { type: 'ability', zone: 'play', uid: z.card.uid }); } catch { threw = true; } + ok(threw, 'Z-95 unusable while the opponent has no capital ship'); + give(st, emp, 'gozanticruiser'); + const handBefore = st.players[reb].hand.length; + actTurn(st, reb, { type: 'ability', zone: 'play', uid: z.card.uid }); + ok(st.players[reb].hand.length === handBefore + 1, 'Z-95 draws once the opponent has a capital ship'); +} + +{ + // General Veers: needs a Trooper or Vehicle in play. Chewbacca: another Hero unit. + const st = freshGame(511); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + const veers = give(st, emp, 'generalveers'); // Officer — doesn't satisfy itself + let threw = false; + try { actTurn(st, emp, { type: 'ability', zone: 'play', uid: veers.card.uid }); } catch { threw = true; } + ok(threw, 'Veers unusable without a Trooper or Vehicle in play'); + give(st, emp, 'atst'); // Vehicle + const handBefore = st.players[emp].hand.length; + actTurn(st, emp, { type: 'ability', zone: 'play', uid: veers.card.uid }); + ok(st.players[emp].hand.length === handBefore + 1, 'Veers draws with a Vehicle in play'); + + const st2 = freshGame(512); + const reb2 = seatOf(st2, 'rebel'); + st2.turnSeat = reb2; + const chewie = give(st2, reb2, 'chewbacca'); + let threw2 = false; + try { actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: chewie.card.uid }); } catch { threw2 = true; } + ok(threw2, 'Chewbacca unusable without another Hero unit'); + give(st2, reb2, 'jynerso'); + const hb2 = st2.players[reb2].hand.length; + actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: chewie.card.uid }); + ok(st2.players[reb2].hand.length === hb2 + 1, 'Chewbacca draws with another Hero unit in play'); +} + +{ + // Han Solo: draw 1, or 2 with the Millennium Falcon in play. + const st = freshGame(513); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const han = give(st, reb, 'hansolo'); + let hb = st.players[reb].hand.length; + actTurn(st, reb, { type: 'ability', zone: 'play', uid: han.card.uid }); + ok(st.players[reb].hand.length === hb + 1, 'Han draws 1 without the Falcon'); + give(st, reb, 'millenniumfalcon'); + const han2 = give(st, reb, 'hansolo'); + hb = st.players[reb].hand.length; + actTurn(st, reb, { type: 'ability', zone: 'play', uid: han2.card.uid }); + ok(st.players[reb].hand.length === hb + 2, 'Han draws 2 with the Falcon in play'); +} + +{ + // Cassian Andor: onBounty trigger fires only when he joins the attack. const st = freshGame(509); const reb = seatOf(st, 'rebel'); const emp = 1 - reb; st.turnSeat = reb; const cassian = give(st, reb, 'cassianandor'); // 5 attack - const target = { uid: 888002, id: 'tiefighter' }; // target 1 - st.galaxy.row[0] = target; + const target = plantRow(st, 0, 'tiefighter'); // target 1 st.players[emp].hand = [{ uid: 'h1', id: 'stormtrooper' }]; actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [cassian.card.uid] }); const d = pendingDecision(st); @@ -369,23 +567,367 @@ function give(st, seat, id) { } { - // Force mechanics: pegged bonus resource, spy choice restrictions. + // onBounty participation: a bystander bounty hunter does NOT trigger. + const st = freshGame(514); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + st.force = 0; + give(st, reb, 'bossk'); // onBounty: gain 1 Force — but he stays home + const commando = give(st, reb, 'rebelcommando'); // 3 attack + const t1 = plantRow(st, 0, 'tiefighter'); + actTurn(st, reb, { type: 'attackRow', targetUid: t1.uid, uids: [commando.card.uid] }); + ok(st.force === 0, "Bossk doesn't trigger when he didn't join the attack"); + const bossk2 = give(st, reb, 'bossk'); + const t2 = plantRow(st, 1, 'tiefighter'); + actTurn(st, reb, { type: 'attackRow', targetUid: t2.uid, uids: [bossk2.card.uid] }); + ok(st.force === 1, 'Bossk gains 1 Force when he defeats the target himself'); +} + +{ + // Bounty rewards: exile decisions and a free own-faction purchase. + const st = freshGame(515); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + const snow = plantRow(st, 0, 'snowspeeder'); // rebel, target 2, reward exile 1 + const trooper = give(st, emp, 'stormtrooper'); // 2 attack + actTurn(st, emp, { type: 'attackRow', targetUid: snow.uid, uids: [trooper.card.uid] }); + let d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'exileCards' && d.optional, 'exile bounty reward queues an optional exile'); + actChooseTarget(st, emp, null); // decline + + const st2 = freshGame(516); + const reb2 = seatOf(st2, 'rebel'); + st2.turnSeat = reb2; + const atat = plantRow(st2, 0, 'atat'); // empire, target 6, reward free purchase + plantRow(st2, 1, 'xwing'); // guarantee a rebel candidate in the row + const c1 = give(st2, reb2, 'chewbacca'); // 5 + const c2 = give(st2, reb2, 'ywing'); // 2 + const resBefore = st2.players[reb2].resources; + actTurn(st2, reb2, { type: 'attackRow', targetUid: atat.uid, uids: [c1.card.uid, c2.card.uid] }); + d = pendingDecision(st2); + ok(d.kind === 'target' && d.op === 'freePurchase', 'free-purchase bounty reward queues a row pick'); + ok(d.candidates.every((c) => cardDef(st2.galaxy.row.find((x) => x.uid === c.uid)).faction === 'rebel'), + 'free purchase offers own-faction cards only'); + const pick = d.candidates[0]; + const pickId = st2.galaxy.row.find((x) => x.uid === pick.uid).id; + actChooseTarget(st2, reb2, pick); + ok(st2.players[reb2].resources === resBefore, 'free purchase costs nothing'); + ok(st2.players[reb2].discard.some((c) => c.id === pickId), 'free purchase lands in the discard'); +} + +{ + // Duros Spy oppChoice: victim with an empty hand auto-resolves to Force gain. + const st = freshGame(607); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + st.force = 0; + st.players[1 - reb].hand = []; + const spy = give(st, reb, 'durosspy'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: spy.card.uid }); + ok(st.force === 1, 'Duros Spy auto-resolves to Force when victim hand is empty'); + + // B-Wing oppChoice: the victim may concede +2 attack instead of discarding. + const st2 = freshGame(608); + const reb2 = seatOf(st2, 'rebel'); + const emp2 = 1 - reb2; + st2.turnSeat = reb2; + const bwing = give(st2, reb2, 'bwing'); + actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: bwing.card.uid }); + const d = pendingDecision(st2); + ok(d.kind === 'oppChoice' && d.seat === emp2 && d.options.length === 2, 'B-Wing forces an opponent choice'); + const boostIdx = d.options.findIndex((o) => o.kind === 'attackBoost'); + actOppChoice(st2, emp2, boostIdx); + ok(entryAttack(st2, reb2, bwing) === 7, 'B-Wing gains 2 attack when the opponent concedes (5+2)'); +} + +{ + // Lando: draw 1 always; opponent discards only while the Force is with you. + const st = freshGame(609); // Force starts with the Rebels + const reb = seatOf(st, 'rebel'); + const emp = 1 - reb; + st.turnSeat = reb; + const lando = give(st, reb, 'landocalrissian'); + const hb = st.players[reb].hand.length; + actTurn(st, reb, { type: 'ability', zone: 'play', uid: lando.card.uid }); + ok(st.players[reb].hand.length === hb + 1, 'Lando draws 1'); + const d = pendingDecision(st); + ok(d.kind === 'oppDiscard' && d.seat === emp, 'with the Force, Lando also forces a discard'); + actOppDiscard(st, emp, st.players[emp].hand[0].uid); + + const st2 = freshGame(610); + const reb2 = seatOf(st2, 'rebel'); + st2.turnSeat = reb2; + st2.force = 0; // Force with no one + const lando2 = give(st2, reb2, 'landocalrissian'); + const hb2 = st2.players[reb2].hand.length; + const oppHand = st2.players[1 - reb2].hand.length; + actTurn(st2, reb2, { type: 'ability', zone: 'play', uid: lando2.card.uid }); + ok(st2.players[reb2].hand.length === hb2 + 1 && st2.players[1 - reb2].hand.length === oppHand, + 'without the Force, Lando only draws'); +} + +{ + // C-ROC Cruiser: discard a hand card to repair 3. + const st = freshGame(611); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const p = st.players[reb]; + p.base.damage = 5; + const croc = give(st, reb, 'crocruiser'); + const costCard = p.hand[0]; + const hb = p.hand.length; + actTurn(st, reb, { type: 'ability', zone: 'capital', uid: croc.card.uid, costUid: costCard.uid }); + ok(p.base.damage === 2, 'C-ROC repairs 3 damage'); + ok(p.hand.length === hb - 1 && p.discard.some((c) => c.uid === costCard.uid), 'the cost card is discarded, not exiled'); +} + +{ + // Twi'lek Smuggler: the next purchase this turn goes on top of the deck. + const st = freshGame(612); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const p = st.players[reb]; + p.resources = 20; + const twilek = give(st, reb, 'twileksmuggler'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: twilek.card.uid }); + ok(p.topdeckNext === true, 'topdeck flag armed'); + const buy = legalActions(st, reb).buys.find((b) => b.uid !== 'outerrim'); + const buyId = st.galaxy.row.find((c) => c.uid === buy.uid).id; + actTurn(st, reb, { type: 'buy', uid: buy.uid }); + ok(p.deck[p.deck.length - 1].id === buyId, 'the purchase lands on top of the deck'); + ok(p.topdeckNext === false, 'flag consumed by the purchase'); +} + +{ + // Fang Fighter: purchased to hand; +1 draw while the Force is with you. + const st = freshGame(613); // Force pegged Rebel + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const p = st.players[reb]; + p.resources = 3; + const fang = plantRow(st, 0, 'fangfighter'); + const hb = p.hand.length; + actTurn(st, reb, { type: 'buy', uid: fang.uid }); + ok(p.hand.some((c) => c.uid === fang.uid), 'Fang Fighter joins the hand'); + ok(p.hand.length === hb + 2, 'and draws a card with the Force'); + ok(!p.discard.some((c) => c.uid === fang.uid), 'it does not touch the discard'); +} + +{ + // Quarren Mercenary: on purchase, exile up to 1 (2 with the Force). + const st = freshGame(614); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + st.players[reb].resources = 4; + const q = plantRow(st, 0, 'quarrenmercenary'); + actTurn(st, reb, { type: 'buy', uid: q.uid }); // Force is with the Rebels → 2 exiles + let d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'exileCards' && d.optional, 'Quarren queues an optional exile'); + actChooseTarget(st, reb, d.candidates[0]); + d = pendingDecision(st); + ok(d && d.kind === 'target' && d.op === 'exileCards', 'a second exile with the Force'); + actChooseTarget(st, reb, null); + ok(st.players[reb].discard.some((c) => c.uid === q.uid), 'Quarren itself lands in the discard'); +} + +{ + // Jawa Scavenger: exile it to purchase from the galaxy discard pile. + const st = freshGame(615); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const p = st.players[reb]; + p.resources = 5; + const wreck = { uid: ++giveUid, id: 'xwing' }; // cost 3, rebel + st.galaxy.discard.push(wreck); + const jawa = give(st, reb, 'jawascavenger'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: jawa.card.uid }); + ok(st.exile.some((c) => c.uid === jawa.card.uid), 'Jawa exiles itself'); + const d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'buyGalaxyDiscard', 'purchase-from-discard decision queued'); + actChooseTarget(st, reb, d.candidates.find((c) => c.uid === wreck.uid)); + ok(p.resources === 2, 'the card cost was paid'); + ok(p.discard.some((c) => c.uid === wreck.uid), 'the salvaged card lands in the player discard'); + ok(!st.galaxy.discard.some((c) => c.uid === wreck.uid), 'and leaves the galaxy discard'); +} + +{ + // Grand Moff Tarkin: borrow an Empire row card; it exiles at end of turn. + const st = freshGame(616); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + const p = st.players[emp]; + const loot = plantRow(st, 0, 'tiefighter'); + const tarkin = give(st, emp, 'grandmofftarkin'); + actTurn(st, emp, { type: 'ability', zone: 'play', uid: tarkin.card.uid }); + const d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'takeRowTemp', 'Tarkin queues a row pick'); + ok(d.candidates.every((c) => cardDef(st.galaxy.row.find((x) => x.uid === c.uid)).faction === 'empire'), + 'only Empire cards offered'); + actChooseTarget(st, emp, d.candidates.find((c) => c.uid === loot.uid)); + ok(p.hand.some((c) => c.uid === loot.uid), 'the borrowed card is in hand'); + ok(p.tempExileUids.includes(loot.uid), 'and flagged for end-of-turn exile'); + actTurn(st, emp, { type: 'endTurn' }); + ok(st.exile.some((c) => c.uid === loot.uid), 'the borrowed card is exiled at end of turn'); + ok(!p.discard.some((c) => c.uid === loot.uid), 'it never reaches the discard'); +} + +{ + // Jyn Erso: look at the opponent's hand; with the Force, topdeck one card. + const st = freshGame(617); // Force with the Rebels + const reb = seatOf(st, 'rebel'); + const emp = 1 - reb; + st.turnSeat = reb; + st.players[emp].hand = [{ uid: 'jh1', id: 'stormtrooper' }, { uid: 'jh2', id: 'imperialshuttle' }]; + const jyn = give(st, reb, 'jynerso'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: jyn.card.uid }); + const evs = takeEvents(st); + ok(evs.some((e) => e.type === 'handReveal' && e.bySeat === reb && e.ids.length === 2), 'hand reveal event emitted'); + const d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'oppTopdeck' && d.candidates.every((c) => c.id), 'topdeck pick offered with card ids'); + actChooseTarget(st, reb, d.candidates.find((c) => c.uid === 'jh1')); + const oppDeck = st.players[emp].deck; + ok(oppDeck[oppDeck.length - 1].uid === 'jh1', 'chosen card sits on top of their deck'); + ok(st.players[emp].hand.length === 1, 'their hand shrank by one'); +} + +{ + // Moff Jerjerrod: peek the top card; with the Force, swap it into the row. + const st = freshGame(618); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + st.force = -FORCE_MAX; // Force with the Empire + const jerjerrod = give(st, emp, 'moffjerjerrod'); + const topBefore = st.galaxy.deck[st.galaxy.deck.length - 1]; + actTurn(st, emp, { type: 'ability', zone: 'play', uid: jerjerrod.card.uid }); + const d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'lookTopSwap' && d.peekId === topBefore.id, 'peek shows the top card'); + const rowRef = d.candidates.find((c) => c.kind === 'row'); + ok(!!rowRef, 'row swap offered while the Force is with you'); + const outUid = rowRef.uid; + actChooseTarget(st, emp, rowRef); + ok(st.galaxy.row.some((c) => c.uid === topBefore.uid), 'top card swapped into the row'); + ok(st.galaxy.deck[st.galaxy.deck.length - 1].uid === outUid, 'row card went on top of the deck'); + + const st2 = freshGame(619); + const emp2 = seatOf(st2, 'empire'); + st2.turnSeat = emp2; // Force with the Rebels → no swap for the Empire + const j2 = give(st2, emp2, 'moffjerjerrod'); + actTurn(st2, emp2, { type: 'ability', zone: 'play', uid: j2.card.uid }); + const d2 = pendingDecision(st2); + ok(d2.op === 'lookTopSwap' && d2.candidates.every((c) => c.kind === 'opt'), 'without the Force, look only'); + actChooseTarget(st2, emp2, d2.candidates[0]); +} + +{ + // destroyCapital scopes: Luke needs one in play; Hammerhead also hits the row. + const st = freshGame(620); // Force with the Rebels + const reb = seatOf(st, 'rebel'); + const emp = 1 - reb; + st.turnSeat = reb; + plantRow(st, 0, 'stardestroyer'); // enemy capital in the row — NOT a Luke target + const luke = give(st, reb, 'lukeskywalker'); + let threw = false; + try { actTurn(st, reb, { type: 'ability', zone: 'play', uid: luke.card.uid }); } catch { threw = true; } + ok(threw, 'Luke has no target while the opponent has no capital in play'); + give(st, emp, 'gozanticruiser'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: luke.card.uid }); + const d = pendingDecision(st); + ok(d.op === 'destroyCapital' && d.candidates.every((c) => c.kind === 'capital'), "Luke's scope is opponent play only"); + actChooseTarget(st, reb, d.candidates[0]); + ok(st.players[emp].capitals.length === 0, 'Luke destroys the capital'); + + const st2 = freshGame(621); + const reb2 = seatOf(st2, 'rebel'); + st2.turnSeat = reb2; + const rowCap = plantRow(st2, 0, 'stardestroyer'); + const hammer = give(st2, reb2, 'hammerheadcorvette'); + actTurn(st2, reb2, { type: 'ability', zone: 'capital', uid: hammer.card.uid }); + ok(st2.exile.some((c) => c.uid === hammer.card.uid), 'Hammerhead exiles itself'); + const d2 = pendingDecision(st2); + ok(d2.op === 'destroyCapital' && d2.candidates.some((c) => c.kind === 'rowCapital' && c.uid === rowCap.uid), + 'Hammerhead can target an enemy capital in the row'); + actChooseTarget(st2, reb2, d2.candidates.find((c) => c.kind === 'rowCapital')); + ok(st2.galaxy.discard.some((c) => c.uid === rowCap.uid), 'row capital destroyed to the galaxy discard'); +} + +{ + // recoverDiscard filters: Falcon (Hero units), AT-AT (Troopers). + const st = freshGame(622); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const p = st.players[reb]; + p.discard.push({ uid: 'fd1', id: 'hansolo' }, { uid: 'fd2', id: 'xwing' }, { uid: 'fd3', id: 'moncalamaricruiser' }); + const falcon = give(st, reb, 'millenniumfalcon'); + actTurn(st, reb, { type: 'ability', zone: 'play', uid: falcon.card.uid }); + const d = pendingDecision(st); + ok(d.op === 'recoverDiscard' && d.candidates.length === 1 && d.candidates[0].uid === 'fd1', + 'Falcon offers only Hero units from the discard'); + actChooseTarget(st, reb, d.candidates[0]); + ok(p.hand.some((c) => c.uid === 'fd1'), 'Han returns to hand'); + + const st2 = freshGame(623); + const emp2 = seatOf(st2, 'empire'); + st2.turnSeat = emp2; + const p2 = st2.players[emp2]; + p2.discard.push({ uid: 'fd4', id: 'stormtrooper' }, { uid: 'fd5', id: 'tiefighter' }); + const atat = give(st2, emp2, 'atat'); + actTurn(st2, emp2, { type: 'ability', zone: 'play', uid: atat.card.uid }); + const d2 = pendingDecision(st2); + ok(d2.op === 'recoverDiscard' && d2.candidates.length === 1 && d2.candidates[0].uid === 'fd4', + 'AT-AT offers only Troopers from the discard'); + actChooseTarget(st2, emp2, d2.candidates[0]); +} + +{ + // revealTop variants: Scout Trooper gains Force, TIE Interceptor draws. + const st = freshGame(624); + const emp = seatOf(st, 'empire'); + st.turnSeat = emp; + st.force = 0; + st.galaxy.deck.push({ uid: ++giveUid, id: 'tiefighter' }); // Empire card on top + const scout = give(st, emp, 'scouttrooper'); + actTurn(st, emp, { type: 'ability', zone: 'play', uid: scout.card.uid }); + ok(st.force === -1, 'Scout Trooper gains 1 Force on an Empire reveal'); + + st.galaxy.deck.push({ uid: ++giveUid, id: 'tiefighter' }); + const inter = give(st, emp, 'tieinterceptor'); + const hb = st.players[emp].hand.length; + actTurn(st, emp, { type: 'ability', zone: 'play', uid: inter.card.uid }); + ok(st.players[emp].hand.length === hb + 1, 'TIE Interceptor draws on an Empire reveal'); + + st.galaxy.deck.push({ uid: ++giveUid, id: 'xwing' }); // enemy card on top + const scout2 = give(st, emp, 'scouttrooper'); + actTurn(st, emp, { type: 'ability', zone: 'play', uid: scout2.card.uid }); + const d = pendingDecision(st); + ok(d.kind === 'target' && d.op === 'revealTopDiscard', 'an enemy reveal offers the discard choice'); + actChooseTarget(st, emp, d.candidates.find((c) => c.v === 'discard')); + ok(st.galaxy.discard.some((c) => c.id === 'xwing'), 'the enemy card can be discarded'); +} + +{ + // Lobot: onPlay choose — attack, resources, or Force. + const st = freshGame(625); + const reb = seatOf(st, 'rebel'); + st.turnSeat = reb; + const p = st.players[reb]; + p.hand.push({ uid: 'lobot1', id: 'lobot' }); + actTurn(st, reb, { type: 'play', uid: 'lobot1' }); + const d = pendingDecision(st); + ok(d.kind === 'chooseOption' && d.options.length === 3, 'Lobot offers three options on play'); + const atkIdx = d.options.findIndex((o) => o.op === 'gainAttack'); + actChooseOption(st, reb, atkIdx); + const lobot = p.inPlay.find((e) => e.card.uid === 'lobot1'); + ok(entryAttack(st, reb, lobot) === 2, 'Lobot gains 2 attack this turn (0 printed + 2)'); +} + +{ + // Force mechanics: pegged bonus resource at start of turn. 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'); } {