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:
Brian Fertig 2026-07-08 22:07:48 -06:00
parent b91879e51e
commit 63bca959c3
9 changed files with 1589 additions and 371 deletions

View File

@ -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
Twilek 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
Jabbas 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 opponents 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 opponents 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 opponents 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
1 Title Cost Attack Resources Force Type Trait Ability Bounty Value Bounty Rewards Count Hit Points Aligned Hero
2 Z-95 Headhunter 1 2 Unit Fighter If your opponent has a capital ship in play, draw 1 card 2 Neutral no
3 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
4 Rodian Gunslinger 2 2 Unit Bounty Hunter This unit gains 2 attack while attacking a target in the galaxy row 2 Neutral no
5 Kel Dor Mystic 2 2 Unit Exile this unit to exile 1 card from your hand or discard pile 2 Neutral no
6 Lobot 3 Unit Officer When you play Lobot, choose: He gains 2 attack, 2 resources, or 2 force this turn 1 Neutral yes
7 Bossk 3 3 Unit Bounty Hunter When Bossk defeats a target in the galaxy row, gain 1 force 1 Neutral yes
8 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
9 Twi’lek Smuggler 3 3 Unit Scoundrel Place the next card you purchase this turn on top of your deck. 2 Neutral no
10 C-Roc Cruiser 3 1 Capital Ship Discard 1 card from your hand to repair 3 damage from your base. 2 3 Neutral no
11 Dengar 4 4 Unit Bounty Hunter When Dengar defeats a target in the galaxy row, gain 2 resources 1 Neutral yes
12 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
13 HWK-290 4 4 Unit Transport Exile this unit to repair 4 damage from your base. 2 Neutral no
14 Blockade Runner 4 1 1 Capital Ship 3 4 Neutral no
15 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
16 Nebulon-B Frigate 5 Capital Ship Chose: Repair 3 damage from your base, or gain 3 resources. 2 5 Neutral no
17 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
18 Jabba’s Sail Barge 7 4 3 Unit Vehicle Add a Bounty Hunter from your discard pile to your hand. 1 Neutral yes
19 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
20 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
21 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
22 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
23 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
24 Rebel Transport 2 Capital Ship Chose: Repair 2 damage from your base, or gain 1 resource 2 2 Rebel Alliance no
25 Chirrut Imwe 3 2 Unit Trooper While the Force is with you, Chirrut Imwe gains 2 attack 3 2 Force 1 Rebel Alliance no
26 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
27 X-Wing 3 3 Unit Fighter If the Force is with you, draw 1 card. 3 3 Resources 3 Rebel Alliance no
28 Chewbacca 4 5 Unit Scoundrel If you have another Hero unit in play, draw 1 card 4 3 Force 1 Rebel Alliance yes
29 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
30 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
31 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
32 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
33 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
34 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
35 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
36 Mon Calamari Cruiser 6 3 Capital Ship 2 6 Rebel Alliance no
37 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
38 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
39 Tie Fighter 1 2 Unit Fighter If you have a capital ship in play, draw 1 card. 1 1 Resource 3 Empire no
40 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
41 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
42 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
43 Death Trooper 3 3 Unit Trooper While the Force is with you, this unit gains 2 attack 3 2 Force 2 Empire no
44 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
45 Gonzanti Cruiser 3 2 Capital Ship Discard 1 card from your hand to draw 1 card 3 3 Empire no
46 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
47 General Veers 4 4 Unit Officer If you have a Trooper or Vehicle in play, draw 1 card 4 3 Force 1 Empire yes
48 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
49 Landing Craft 4 Unit Transport Choose: Gain 4 resources, or repair 4 damage from your base. 4 4 Resources 2 Empire no
50 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
51 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
52 Imperial Carrier 5 3 Capital Ship While Imperial Carrier is in play, each of your fighter units gain 1 attack 2 5 Empire no
53 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
54 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
55 Star Destroyer 7 4 Capital Ship 2 7 Empire no
56 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

View File

@ -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": {

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 5565** (frame 55 is the old spare
top-sheet cell; 5665 live in the two new rows; 6671 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 (065), 6671 spare |
| **Status** | ✅ frames 054 painted (existing sheet) · ⛔ frames 5565 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 |
| 6671 | — | spare | | leave blank for future cards |
> Priority: uniques (1418, 2835, 40, 4952, 54) and starters/commons that
> show up every game (05, 79, 2224, 2627) read best painted first.
New-frame grid positions (300 × 155 px cells):
| Frame | Row | Col | Pixel box (x, y) |
|---:|---:|---:|---|
| 55 | 6 | 7 | 21002400, 9301085 |
| 5663 | 7 | 07 | 02400, 10851240 |
| 64, 65 | 8 | 0, 1 | 0600, 12401395 |
> 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 (054); then set `cardSheet.path` in `swdbg-artwork.json`.
- Priority 1 (uniques/leaders): 1418, 2835, 40, 4952, 54
- Priority 2 (common starters): 05, 79, 2224, 2627
- Priority 3 (remaining Empire/Rebel commons): 1013, 1921, 3639
- Priority 4 (neutral/outer rim): 6, 4148, 53
- [ ] `swdbg-bases-art.png` — 1100×600 (5×2 grid), 220×300 per frame, 10 frames (09); then set `baseSheet.path` in `swdbg-artwork.json`.
- [ ] `game-icons.png` frame 78 — 44×44 menu icon.
- [ ] `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).

View File

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