feat(swdbg): overhaul card data from physical set transcription
Regenerate all galaxy cards from the owner's physical card CSV (assets/info/sw-force-cards.csv) with verified stats, abilities, and bounty rewards. Cross-check is enforced by tools/verifySWDBG.js. New mechanics: - oppChoice: opponent picks between discard / giveForce / attackBoost (Duros Spy, B-Wing) - dealDamage: exile-self effect targeting base OR enemy capital (Y-Wing) - buyGalaxyDiscard: exile a unit to purchase from the discard pile (Jawa Scavenger) - topdeckNextPurchase: next buy goes on deck top (Twi'lek Smuggler) - takeRowTemp: borrow a row card, exile it at turn end (Grand Moff Tarkin) - lookTopSwap: peek deck top, swap with a row card while Force is yours (Moff Jerjerrod) - lookOppHand: reveal opponent's hand, optionally topdeck a card (Jyn Erso) - purchaseToHand: onPurchase adds card to hand instead of discard (Fang Fighter) New ability gates: requiresOppCapital, requiresTraitInPlay, requiresOtherHero, requiresForce (on multi effects). New constant ops: typeBoost (Admiral Piett), selfBoostPerBaseLost (Baze Malbus). New timing: onPurchase. Updated 55+ cards with corrected costs, attacks, HP, counts, traits, and abilities. Added 11 new frames to the art sheet (55–65); 4 frames orphaned but preserved. Adds ~20 scripted rule spot-checks covering all new mechanics.
This commit is contained in:
parent
b91879e51e
commit
63bca959c3
|
|
@ -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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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? }",
|
||||
"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 you defeat a galaxy-row card",
|
||||
" '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 { 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",
|
||||
" 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": {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
// 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);
|
||||
return worst && cardValue(cardDef(worst)) <= 1.2 ? 'discard' : 'force';
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
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',
|
||||
});
|
||||
});
|
||||
}
|
||||
if (d.options.includes('force')) {
|
||||
this.modalButton(cx, y, 'Let them gain 1 Force', () => this.applyDecision(d, 'force'), { width: 420, variant: '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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue