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, "tiefighter": 7, "scouttrooper": 8, "deathtrooper": 9, "tiebomber": 10,
"tieinterceptor": 11, "atst": 12, "atat": 13, "generalveers": 14, "tieinterceptor": 11, "atst": 12, "atat": 13, "generalveers": 14,
"grandmofftarkin": 15, "directorkrennic": 16, "darthvader": 17, "grandmofftarkin": 15, "directorkrennic": 16, "darthvader": 17,
"emperorpalpatine": 18, "gozanticruiser": 19, "imperialcarrier": 20, "gozanticruiser": 19, "imperialcarrier": 20,
"stardestroyer": 21, "stardestroyer": 21,
"rebelpathfinder": 22, "ywing": 23, "snowspeeder": 24, "durosspy": 25, "ywing": 23, "snowspeeder": 24, "durosspy": 25,
"xwing": 26, "rebelcommando": 27, "cassianandor": 28, "k2so": 29, "xwing": 26, "rebelcommando": 27, "cassianandor": 28,
"chirrutimwe": 30, "jynerso": 31, "chewbacca": 32, "hansolo": 33, "chirrutimwe": 30, "jynerso": 31, "chewbacca": 32, "hansolo": 33,
"princessleia": 34, "lukeskywalker": 35, "blockaderunner": 36, "princessleia": 34, "lukeskywalker": 35, "blockaderunner": 36,
"hammerheadcorvette": 37, "medicalfrigate": 38, "moncalamaricruiser": 39, "hammerheadcorvette": 37, "moncalamaricruiser": 39,
"millenniumfalcon": 40, "millenniumfalcon": 40,
"z95headhunter": 41, "jawascavenger": 42, "keldormystic": 43, "z95headhunter": 41, "jawascavenger": 42, "keldormystic": 43,
"rodiangunslinger": 44, "hwk290": 45, "twileksmuggler": 46, "rodiangunslinger": 44, "hwk290": 45, "twileksmuggler": 46,
"fangfighter": 47, "quarrenmercenary": 48, "bossk": 49, "ig88": 50, "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": { "bases": {

View File

@ -4,62 +4,102 @@
"source of truth for every card: edit stats/text here and the game engine", "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).", "picks the changes up on the next page load (no rebuild needed).",
"", "",
"RULES DATA CAVEAT: the rules engine follows the official rulebook exactly,", "Galaxy cards were transcribed from the owner's physical set",
"but individual card stats/abilities below were reconstructed from memory and", "(assets/info/sw-force-cards.csv). tools/verifySWDBG.js cross-checks this",
"public previews — verify them against the physical cards and edit freely.", "file against that CSV, so keep the two in sync when editing.",
"", "",
"CARD FIELDS:", "CARD FIELDS:",
" id unique lowercase key (do not change once art is mapped)", " id unique lowercase key (do not change once art is mapped)",
" name display name", " name display name",
" faction 'empire' | 'rebel' | 'neutral'", " faction 'empire' | 'rebel' | 'neutral'",
" type 'unit' | 'capital' (capital ships stay in play, have hp)", " 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, ...)", " traits flavor tags referenced by abilities (Fighter, Trooper, ...)",
" cost purchase cost in resources", " cost purchase cost in resources",
" attack / resources / force printed values gained when played", " attack / resources / force printed values gained when played",
" hp capital ships only — damage needed to destroy it in play", " 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", " target attack needed to bounty-hunt/sabotage it in the row (faction",
" cards only; neutral cards and capital ships use null)", " units only; neutral cards and capital ships use null)",
" reward what the defeater gains: {resources, force, draw} or 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", " ability machine-readable effect (see OPS below) or null",
" text display-only rules text — keep in sync with `ability`", " 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)", " timing 'action' = use once per turn while in play (the default)",
" 'constant' = always on while in play ('While ...')", " 'constant' = always on while in play ('While ...')",
" 'onPlay' = triggers when the card is played", " '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",
" 'onReveal' = bases only: when the base is revealed", " target ('When X defeats ...')",
" cost { exileSelf: true } and/or { exileFromHand: 1 } paid before the op", " 'onPurchase' = triggers when you purchase this card",
" requiresForce: true = usable only while the Force is with you", " 'onReveal' = bases only: when the base is revealed",
" 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", " nForce / amountForce = upgraded number while the Force is with you",
"", "",
"SUPPORTED OPS (the engine understands exactly these):", "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", " gainResources{n,nForce} add resources to your pool this turn",
" gainForce{n} move the Force marker toward you", " gainForce{n} move the Force marker toward you",
" gainAttack{n,nForce} this card gains attack this turn", " gainAttack{n,nForce} this card gains attack this turn",
" damageBase{n,nForce} damage enemy base directly (ignores capitals)", " 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", " repairBase{n} remove damage from your base",
" oppDiscard{n,randomForce} opponent discards (random while Force is w/ you)", " 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)", " discardRow{} discard a card from the galaxy row (no reward)",
" destroyCapital{} destroy a capital ship (in play or in the row)", " destroyCapital{scope} destroy a capital ship. scope 'oppPlay' = only",
" exileCards{n,optional} exile up to n cards from your hand/discard", " 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", " 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", " lookTop{mayDiscard} look at top of galaxy deck, optionally discard",
" revealTop{} reveal top of galaxy deck; if it matches your", " lookTopSwap{} look at top of galaxy deck; while the Force is",
" faction gain 1 resource, if enemy you may", " with you, you may swap it with a galaxy-row card",
" discard it, if neutral nothing happens", " lookOppHand{forceTopdeck} look at opponent's hand; while the Force is with",
" spyChoice{} opponent chooses: discard 1 OR you gain 1 Force", " you, put 1 card from it on top of their deck",
" choose{options:[a,b]} pick one of two sub-abilities", " 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", " 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", " rowBonus{amount} constant: +attack when attacking the galaxy row",
" selfBoostWhileForce{amount} constant: +attack while the Force is with you", " selfBoostWhileForce{amount} constant: +attack while the Force is with you",
" selfBoostWithTrait{trait,amount} constant: +attack while you have another", " selfBoostWithTrait{trait,amount} constant: +attack while you have another",
" card with the trait in play", " card with the trait in play",
" selfBoostPerBaseLost{amount} constant: +attack per base you have lost",
" capitalDiscount{n} constant (bases): your capital ships cost less", " capitalDiscount{n} constant (bases): your capital ships cost less",
" onPlayTraitAttack{trait,amount} constant (bases): played units with the", " onPlayTraitAttack{trait,amount} constant (bases): played units with the",
" trait gain +attack", " trait gain +attack",
@ -112,152 +152,173 @@
}, },
"cards": [ "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 }, { "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, "requiresCapital": true }, "ability": { "timing": "action", "op": "draw", "n": 1, "requiresOppCapital": true },
"text": "If you have a capital ship in play, draw 1 card." }, "text": "If your opponent has 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 }, { "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": "revealTop" }, "ability": { "timing": "action", "op": "buyGalaxyDiscard", "cost": { "exileSelf": true } },
"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." }, "text": "Exile this unit to purchase a card from the galaxy discard pile as if it were in the galaxy row." },
{ "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 }, { "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": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, "ability": { "timing": "constant", "op": "rowBonus", "amount": 2 },
"text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, "text": "This unit gains 2 attack while attacking a target in the galaxy row." },
{ "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 }, { "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": "damageBase", "n": 1, "nForce": 2 }, "ability": { "timing": "action", "op": "exileCards", "n": 1, "optional": true, "cost": { "exileSelf": true } },
"text": "Deal 1 damage to the enemy base (2 damage instead while the Force is with you)." }, "text": "Exile this unit to exile 1 card from your hand or discard pile." },
{ "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 }, { "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": "constant", "op": "selfBoostWhileForce", "amount": 1 }, "ability": { "timing": "onPlay", "op": "choose", "options": [ { "op": "gainAttack", "n": 2 }, { "op": "gainResources", "n": 2 }, { "op": "gainForce", "n": 2 } ] },
"text": "TIE Interceptor gains 1 attack while the Force is with you." }, "text": "When you play Lobot, choose: he gains 2 attack, 2 resources, or 2 Force this turn." },
{ "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 }, { "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": null, "ability": { "timing": "onBounty", "op": "gainForce", "n": 1 },
"text": "The chicken walker: ridiculous nickname, unfunny results." }, "text": "When Bossk defeats a target in the galaxy row, gain 1 Force." },
{ "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 }, { "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": "action", "op": "oppDiscard", "n": 1, "randomForce": true }, "ability": { "timing": "onPurchase", "op": "multi", "effects": [ { "op": "purchaseToHand" }, { "op": "draw", "n": 1, "requiresForce": true } ] },
"text": "Your opponent discards a card from their hand (at random while the Force is with you)." }, "text": "When you purchase this unit, add it to your hand (and draw 1 card if 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 }, { "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": "constant", "op": "traitBoost", "trait": "Vehicle", "amount": 2 }, "ability": { "timing": "action", "op": "topdeckNextPurchase" },
"text": "Your Vehicles gain 2 attack while General Veers is in play." }, "text": "Place the next card you purchase this turn on top of your deck." },
{ "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 }, { "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": "discardRow" }, "ability": { "timing": "action", "op": "repairBase", "n": 3, "cost": { "discardFromHand": 1 } },
"text": "You may discard a card from the galaxy row." }, "text": "Discard 1 card from your hand to repair 3 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 }, { "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": "action", "op": "draw", "n": 1, "nBase": { "base": "deathstar", "n": 2 } }, "ability": { "timing": "onBounty", "op": "gainResources", "n": 2 },
"text": "Draw 1 card (2 cards instead if your base is the Death Star)." }, "text": "When Dengar defeats a target in the galaxy row, gain 2 resources." },
{ "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 }, { "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": "constant", "op": "selfBoostWhileForce", "amount": 4 }, "ability": { "timing": "onPurchase", "op": "exileCards", "n": 1, "nForce": 2, "optional": true },
"text": "Darth Vader gains 4 attack while the Force is with you." }, "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": "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 }, { "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": "choose", "options": [ { "op": "draw", "n": 2 }, { "op": "oppDiscard", "n": 1, "randomForce": true } ] }, "ability": { "timing": "action", "op": "repairBase", "n": 4, "cost": { "exileSelf": true } },
"text": "Choose one: draw 2 cards, or your opponent discards a card from their hand (at random while the Force is with you)." }, "text": "Exile this unit to repair 4 damage from your base." },
{ "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, { "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": "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,
"ability": null, "ability": null,
"text": "Though designed as a diplomatic courier, the Corellian CR90 corvette is often used to run planetary blockades." }, "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, { "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": "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,
"ability": { "timing": "onBounty", "op": "exileCards", "n": 1, "optional": true }, "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." }, "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, { "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": "onBounty", "op": "draw", "n": 1 }, "ability": { "timing": "action", "op": "choose", "options": [ { "op": "repairBase", "n": 3 }, { "op": "gainResources", "n": 3 } ] },
"text": "When you defeat a card in the galaxy row, draw a card." }, "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, { "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 } }, "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)." }, "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, "ability": null,
"text": "A smuggler's idea of a warship." }, "text": "The backbone of the Rebel fleet, crewed by the galaxy's finest shipwrights." },
{ "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, { "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, "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": { "bases": {

View File

@ -60,7 +60,7 @@ function chooseTurn(view, seat, skill, rnd) {
if (legal.plays.length) { if (legal.plays.length) {
const needsFodder = [...p.inPlay, ...p.capitals].some((e) => { const needsFodder = [...p.inPlay, ...p.capitals].some((e) => {
const ab = cardDef(e.card).ability; 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; let playable = p.hand;
if (needsFodder && p.hand.length === 1) playable = []; 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 }; const action = { type: 'ability', zone: a.zone, uid: a.uid };
// Supply the exile cost card when needed. // Supply the exile cost card when needed.
const def = a.zone === 'base' ? baseDef(p.faction, p.base.id) : cardDef(findInPlay(p, a.uid).card); 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); const fodder = worstHandCard(view, seat);
if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal); if (!fodder) return nextAfterAbilities(view, seat, skill, rnd, legal);
action.costUid = fodder.uid; action.costUid = fodder.uid;
@ -107,7 +107,8 @@ function nextAfterAbilities(view, seat, skill, rnd, legal) {
const picked = pickBountySquad(view, seat, tdef.target); const picked = pickBountySquad(view, seat, tdef.target);
if (!picked) continue; if (!picked) continue;
const r = tdef.reward || {}; 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 denialV = cardValue(tdef) * 0.45; // enemy can no longer buy it
const spent = picked.total * 0.7; // base damage forfeited const spent = picked.total * 0.7; // base damage forfeited
const s = rewardV + denialV - spent; const s = rewardV + denialV - spent;
@ -241,6 +242,54 @@ function chooseTarget(view, seat, skill, rnd, decision) {
const discard = enemy || junk; const discard = enemy || junk;
return cands.find((c) => c.v === (discard ? 'discard' : 'keep')) || cands[0]; 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: default:
return decision.optional ? null : cands[0] || null; return decision.optional ? null : cands[0] || null;
} }
@ -253,20 +302,37 @@ function chooseOppDiscard(view, seat) {
return worst ? worst.uid : null; return worst ? worst.uid : null;
} }
function chooseSpy(view, seat, decision) { // Victim picks the least painful oppChoice option (Duros Spy, B-Wing).
if (!decision.options.includes('discard')) return 'force'; function chooseOppChoice(view, seat, decision) {
if (!decision.options.includes('force')) return 'discard'; let best = 0, bestPain = Infinity;
const worst = worstHandCard(view, seat); decision.options.forEach((option, i) => {
return worst && cardValue(cardDef(worst)) <= 1.2 ? 'discard' : 'force'; let pain;
switch (option.kind) {
case 'discardSelf': {
const worst = worstHandCard(view, seat);
pain = worst ? cardValue(cardDef(worst)) : Infinity;
break;
}
case 'giveForce': pain = (option.n || 1) * 1.2; break;
case 'attackBoost': pain = (option.n || 1) * 0.9; break;
default: pain = 1;
}
if (pain < bestPain) { bestPain = pain; best = i; }
});
return best;
} }
function chooseOption(view, seat, skill, rnd, decision) { function chooseOption(view, seat, skill, rnd, decision) {
const p = me(view, seat);
const o = foe(view, seat); const o = foe(view, seat);
const scored = decision.options.map((ab, i) => { const scored = decision.options.map((ab, i) => {
let s = 1; let s = 1;
if (ab.op === 'draw') s = (ab.n || 1) * 1.4; if (ab.op === 'draw') s = (ab.n || 1) * 1.4;
if (ab.op === 'oppDiscard') s = o.hand.length ? 1.6 : 0; if (ab.op === 'oppDiscard') s = o.hand.length ? 1.6 : 0;
if (ab.op === 'gainResources') s = (ab.n || 1) * 1.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 { choice: i, s };
}); });
return pickScored(scored, skill, rnd) ?? 0; return pickScored(scored, skill, rnd) ?? 0;
@ -275,7 +341,7 @@ function chooseOption(view, seat, skill, rnd, decision) {
// ── Router ────────────────────────────────────────────────────────────────── // ── Router ──────────────────────────────────────────────────────────────────
// Returns the argument for the matching act* mutator: // Returns the argument for the matching act* mutator:
// turn → action object · chooseBase → baseId · target → ref|null · // 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) { export function decide(view, decision, skill, rnd = Math.random) {
const seat = decision.seat; const seat = decision.seat;
switch (decision.kind) { 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 'chooseBase': return chooseBase(view, seat, skill, rnd, decision);
case 'target': return chooseTarget(view, seat, skill, rnd, decision); case 'target': return chooseTarget(view, seat, skill, rnd, decision);
case 'oppDiscard': return chooseOppDiscard(view, seat); 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); case 'chooseOption': return chooseOption(view, seat, skill, rnd, decision);
default: return null; 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 // 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. // against this list so a data edit can't silently no-op.
export const OPS = [ export const OPS = [
'draw', 'gainResources', 'gainForce', 'gainAttack', 'damageBase', 'repairBase', 'draw', 'gainResources', 'gainForce', 'gainAttack', 'damageBase', 'dealDamage',
'oppDiscard', 'discardRow', 'destroyCapital', 'exileCards', 'freePurchase', 'repairBase', 'oppDiscard', 'oppChoice', 'discardRow', 'destroyCapital',
'recoverDiscard', 'lookTop', 'revealTop', 'spyChoice', 'choose', 'exileCards', 'freePurchase', 'buyGalaxyDiscard', 'topdeckNextPurchase',
'traitBoost', 'rowBonus', 'selfBoostWhileForce', 'selfBoostWithTrait', 'recoverDiscard', 'lookTop', 'lookTopSwap', 'lookOppHand', 'takeRowTemp',
'revealTop', 'choose', 'multi', 'purchaseToHand',
'traitBoost', 'typeBoost', 'rowBonus', 'selfBoostWhileForce',
'selfBoostWithTrait', 'selfBoostPerBaseLost',
'capitalDiscount', 'onPlayTraitAttack', '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. // 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; 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 { loadCardData, cardDef, baseDef, basesFor, getData, FACTION_INFO } from './SWDBGData.js';
import { import {
newGame, pendingDecision, takeEvents, publicView, legalActions, newGame, pendingDecision, takeEvents, publicView, legalActions,
actTurn, actChooseBase, actChooseTarget, actOppDiscard, actSpyChoice, actChooseOption, actTurn, actChooseBase, actChooseTarget, actOppDiscard, actOppChoice, actChooseOption,
entryAttack, forceWith, purchaseCost, FORCE_MAX, entryAttack, forceWith, purchaseCost, FORCE_MAX,
} from './SWDBGLogic.js'; } from './SWDBGLogic.js';
import { decide, nextThinkDelay } from './SWDBGAI.js'; import { decide, nextThinkDelay } from './SWDBGAI.js';
@ -257,7 +257,11 @@ export default class SWDBGGame extends Phaser.Scene {
artFor(id, base = false) { artFor(id, base = false) {
const sheet = base ? this.art.baseSheet : this.art.cardSheet; const sheet = base ? this.art.baseSheet : this.art.cardSheet;
const frame = (base ? this.art.bases : this.art.cards)?.[id]; 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 { key: sheet.key, frame };
} }
return null; return null;
@ -1204,6 +1208,18 @@ export default class SWDBGGame extends Phaser.Scene {
onClick: () => this.onHandClicked(inst), onClick: () => this.onHandClicked(inst),
}); });
if (!playable && !discardable) this.handLayer.list[this.handLayer.list.length - 1].setAlpha(0.9); 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 p = this.gs.players[this.humanSeat];
const def = target.zone === 'base' ? baseDef(p.faction, p.base.id) 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); : 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 // 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 }); this.applyDecision(d, { type: 'ability', zone: target.zone, uid: target.uid, costUid: uid });
}); });
return; return;
@ -1390,7 +1407,7 @@ export default class SWDBGGame extends Phaser.Scene {
case 'chooseBase': actChooseBase(this.gs, d.seat, choice); break; case 'chooseBase': actChooseBase(this.gs, d.seat, choice); break;
case 'target': actChooseTarget(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 '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; case 'chooseOption': actChooseOption(this.gs, d.seat, choice); break;
default: break; default: break;
} }
@ -1436,9 +1453,9 @@ export default class SWDBGGame extends Phaser.Scene {
this.setPrompt('Enemy pressure! Choose a card to discard'); this.setPrompt('Enemy pressure! Choose a card to discard');
break; break;
} }
case 'spyChoice': { case 'oppChoice': {
this.mode = { type: 'spyChoice', decision: d }; this.mode = { type: 'oppChoice', decision: d };
this.showSpyModal(d); this.showOppChoiceModal(d);
break; break;
} }
case 'chooseOption': { case 'chooseOption': {
@ -1478,6 +1495,23 @@ export default class SWDBGGame extends Phaser.Scene {
case 'revealTopDiscard': case 'revealTopDiscard':
this.showPeekModal(d, 'Revealed from the galaxy deck', 'Leave on top', 'Discard it'); this.showPeekModal(d, 'Revealed from the galaxy deck', 'Leave on top', 'Discard it');
break; 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: default:
// Fallback: auto-pick the first candidate so nothing soft-locks. // Fallback: auto-pick the first candidate so nothing soft-locks.
this.applyDecision(d, d.candidates[0] || null); 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 root = this.modalRoot();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; 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, fontFamily: 'Righteous', fontSize: '30px', color: C.badHex,
}).setOrigin(0.5)); }).setOrigin(0.5));
root.add(this.add.text(cx, cy - 70, 'Their agent forces a choice:', { root.add(this.add.text(cx, cy - 70, 'Their agent forces a choice:', {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: C.text, fontFamily: '"Julius Sans One"', fontSize: '19px', color: C.text,
}).setOrigin(0.5)); }).setOrigin(0.5));
let y = cy; const label = (o) => {
if (d.options.includes('discard')) { if (o.kind === 'discardSelf') return 'Discard a card from your hand';
this.modalButton(cx, y, 'Discard a card from your hand', () => this.applyDecision(d, 'discard'), { width: 420 }); if (o.kind === 'giveForce') return `Let them gain ${o.n || 1} Force`;
y += 60; if (o.kind === 'attackBoost') return `Let their unit gain ${o.n || 1} attack`;
} return o.kind;
if (d.options.includes('force')) { };
this.modalButton(cx, y, 'Let them gain 1 Force', () => this.applyDecision(d, 'force'), { width: 420, variant: 'ghost' }); d.options.forEach((o, i) => {
this.modalButton(cx, cy + i * 60, label(o), () => this.applyDecision(d, i), {
width: 420, variant: i === 0 ? undefined : 'ghost',
});
});
}
// Y-Wing: pick the enemy base or one of their capital ships to take the hit.
showDealDamageModal(d) {
const root = this.modalRoot();
const cx = GAME_WIDTH / 2;
root.add(this.add.text(cx, 300, 'CHOOSE A TARGET', {
fontFamily: 'Righteous', fontSize: '28px', color: C.badHex,
}).setOrigin(0.5));
const o = this.gs.players[1 - this.humanSeat];
const items = d.candidates.map((ref) => {
if (ref.kind === 'oppBase') return { ref, base: true };
const e = o.capitals.find((x) => x.card.uid === ref.uid);
return e ? { ref, inst: e.card, damage: e.damage } : null;
}).filter(Boolean);
const pitch = Math.min(260, 1400 / Math.max(1, items.length));
const x0 = cx - ((items.length - 1) * pitch) / 2;
items.forEach((it, i) => {
const x = x0 + i * pitch;
if (it.base) {
this.makeBaseCard(x, 540, o.faction, o.base, 240, 152, {
parent: root, hover: false, showText: false, highlight: true,
onClick: () => this.applyDecision(d, it.ref),
});
root.add(this.add.text(x, 650, 'enemy base', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
}).setOrigin(0.5));
} else {
this.makeCard(x, 540, it.inst, 190, 264, {
parent: root, hover: false, showText: true,
onClick: () => this.applyDecision(d, it.ref),
});
root.add(this.add.text(x, 690, `${it.damage} damage taken`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.muted,
}).setOrigin(0.5));
}
});
}
// Jawa Scavenger: purchase (pay cost) from the galaxy discard pile.
showGalaxyDiscardModal(d) {
const root = this.modalRoot();
const cx = GAME_WIDTH / 2;
root.add(this.add.text(cx, 240, 'PURCHASE FROM THE GALAXY DISCARD PILE', {
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
}).setOrigin(0.5));
const cards = d.candidates
.map((ref) => ({ ref, inst: this.gs.galaxy.discard.find((c) => c.uid === ref.uid) }))
.filter((c) => c.inst);
const cols = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(cards.length * 1.6))));
const cw = 150, chh = 208;
cards.forEach((c, i) => {
const x = cx - ((Math.min(cols, cards.length) - 1) * (cw + 12)) / 2 + (i % cols) * (cw + 12);
const y = 430 + Math.floor(i / cols) * (chh + 14);
this.makeCard(x, y, c.inst, cw, chh, {
parent: root, hover: false, showText: true,
onClick: () => this.applyDecision(d, c.ref),
});
});
if (d.optional) this.modalButton(cx, GAME_HEIGHT - 110, 'Skip', () => this.applyDecision(d, null), { variant: 'ghost', width: 180 });
}
// Moff Jerjerrod: peek the galaxy deck's top card; with the Force, may swap
// it with a row card (the row card goes on top of the deck).
showLookTopSwapModal(d) {
const root = this.modalRoot();
const cx = GAME_WIDTH / 2;
const rowRefs = d.candidates.filter((c) => c.kind === 'row');
root.add(this.add.text(cx, 210, 'TOP OF THE GALAXY DECK', {
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
}).setOrigin(0.5));
if (d.peekId) this.makeCard(cx, 400, { uid: -1, id: d.peekId }, 200, 278, { parent: root, hover: false, showText: true, noHoverPreview: true });
if (rowRefs.length) {
root.add(this.add.text(cx, 580, 'The Force is with you — swap it with a galaxy-row card?', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: C.text,
}).setOrigin(0.5));
const cards = rowRefs
.map((ref) => ({ ref, inst: this.gs.galaxy.row.find((c) => c.uid === ref.uid) }))
.filter((c) => c.inst);
const pitch = Math.min(180, 1400 / Math.max(1, cards.length));
const x0 = cx - ((cards.length - 1) * pitch) / 2;
cards.forEach((c, i) => {
this.makeCard(x0 + i * pitch, 750, c.inst, 150, 208, {
parent: root, hover: false, showText: true,
onClick: () => this.applyDecision(d, c.ref),
});
});
} }
this.modalButton(cx, GAME_HEIGHT - 90, 'Leave it on top', () => this.applyDecision(d, d.candidates.find((c) => c.v === 'keep')), { width: 300, variant: rowRefs.length ? 'ghost' : undefined });
}
// Jyn Erso: the opponent's hand is revealed — pick one card to place on top
// of their deck.
showOppTopdeckModal(d) {
const root = this.modalRoot();
const cx = GAME_WIDTH / 2;
root.add(this.add.text(cx, 300, "OPPONENT'S HAND — PICK ONE TO TOPDECK", {
fontFamily: 'Righteous', fontSize: '26px', color: C.goldHex,
}).setOrigin(0.5));
root.add(this.add.text(cx, 345, 'The chosen card goes on top of their deck', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted,
}).setOrigin(0.5));
const pitch = Math.min(200, 1400 / Math.max(1, d.candidates.length));
const x0 = cx - ((d.candidates.length - 1) * pitch) / 2;
d.candidates.forEach((ref, i) => {
this.makeCard(x0 + i * pitch, 560, { uid: ref.uid, id: ref.id }, 170, 236, {
parent: root, hover: false, showText: true,
onClick: () => this.applyDecision(d, ref),
});
});
} }
showOptionModal(d) { showOptionModal(d) {
@ -1568,9 +1715,11 @@ export default class SWDBGGame extends Phaser.Scene {
switch (ab.op) { switch (ab.op) {
case 'draw': return `Draw ${ab.n || 1} card${(ab.n || 1) > 1 ? 's' : ''}`; case 'draw': return `Draw ${ab.n || 1} card${(ab.n || 1) > 1 ? 's' : ''}`;
case 'oppDiscard': return 'Opponent discards a card'; 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 '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 'damageBase': return `Deal ${ab.n} damage to the enemy base`;
case 'repairBase': return `Repair ${ab.n} damage from your base`;
default: return ab.op; 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 === 'draw' || e.type === 'rowRefill' || e.type === 'play') && e.uid != null) this._pendingCardUids.add(e.uid);
if (e.type === 'buy' && e.uid != null) { if (e.type === 'buy' && e.uid != null) {
if (this._rowPos.has(e.uid)) this._rowGhosts.set(e.uid, e.id); 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)) { if (e.type === 'bounty' && e.uid != null && this._rowPos.has(e.uid)) {
this._rowGhosts.set(e.uid, e.id); this._rowGhosts.set(e.uid, e.id);
@ -1785,10 +1937,16 @@ export default class SWDBGGame extends Phaser.Scene {
case 'forceBonus': return 240; case 'forceBonus': return 240;
case 'reveal': return 700; case 'reveal': return 700;
case 'exile': return 260; 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 'recover': return 300;
case 'repair': return 300; case 'repair': return 300;
case 'rowDiscard': return 340; 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 'galaxyReshuffle': return 300;
case 'draw': return this._dealingInitial ? 300 : 260; case 'draw': return this._dealingInitial ? 300 : 260;
case 'rowRefill': return this._dealingInitial ? 320 : 280; 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 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 cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const bigW = 316, bigH = 444; // 2x the on-board row/outer-rim card size const bigW = 316, bigH = 444; // 2x the on-board row/outer-rim card size
const dest = e.topdeck const destKind = e.dest || (e.topdeck ? 'deck' : 'discard');
? (e.seat === this.humanSeat ? { x: 392, y: 911, w: 90, h: 124 } : { x: 570, y: 132, w: 30, h: 42 }) let dest, stowFlip = 'back';
: (e.seat === this.humanSeat ? { x: GAME_WIDTH - 205, y: 985, w: 90, h: 124 } : { x: 570, y: 192, w: 30, h: 42 }); 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 = () => { const land = () => {
this._pendingCardUids.delete(e.uid); this._pendingCardUids.delete(e.uid);
this._pendingDiscardUids.delete(e.uid); this._pendingDiscardUids.delete(e.uid);
@ -2004,7 +2171,7 @@ export default class SWDBGGame extends Phaser.Scene {
this.dealCardAnimated({ this.dealCardAnimated({
fromX: cx, fromY: cy, fromW: bigW, fromH: bigH, fromX: cx, fromY: cy, fromW: bigW, fromH: bigH,
toX: dest.x, toY: dest.y, toW: dest.w, toH: dest.h, 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, 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); this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} exiled`, '#b06bd8', 18);
break; break;
case 'discard': 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; 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': case 'recover':
this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} returns to hand`, '#3fbf6f', 18); this.popText(GAME_WIDTH / 2, 700, `${cardDef(e.id).name} returns to hand`, '#3fbf6f', 18);
break; 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), baseDeck: bases.filter((b) => !b.starting).map((b) => b.id),
lostBases: 0, lostBases: 0,
resources: 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 = { const state = {
@ -97,9 +99,19 @@ export function forcePegged(state, seat) {
return state.force * forceSign(p.faction) >= FORCE_MAX; 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.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; return true;
} }
@ -165,13 +177,15 @@ export function entryAttack(state, seat, entry, { vsRow = false } = {}) {
if (ab.op === 'selfBoostWithTrait' if (ab.op === 'selfBoostWithTrait'
&& allMine.some((e) => e !== entry && (cardDef(e.card).traits || []).includes(ab.trait))) atk += ab.amount; && 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 === '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) { for (const e of allMine) {
if (e === entry) continue; if (e === entry) continue;
const oab = cardDef(e.card).ability; const oab = cardDef(e.card).ability;
if (oab && oab.timing === 'constant' && oab.op === 'traitBoost' if (!oab || oab.timing !== 'constant') continue;
&& (def.traits || []).includes(oab.trait)) atk += oab.amount; 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; return atk;
} }
@ -232,7 +246,21 @@ function abilityCandidates(state, seat, ab, ctx = {}) {
return state.galaxy.row.map((c) => ({ kind: 'row', uid: c.uid })); return state.galaxy.row.map((c) => ({ kind: 'row', uid: c.uid }));
case 'destroyCapital': { case 'destroyCapital': {
const out = o.capitals.map((e) => ({ kind: 'capital', seat: o.seat, uid: e.card.uid })); 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; return out;
} }
case 'exileCards': { case 'exileCards': {
@ -242,8 +270,28 @@ function abilityCandidates(state, seat, ab, ctx = {}) {
} }
case 'recoverDiscard': case 'recoverDiscard':
return p.discard 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 })); .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': case 'freePurchase':
return state.galaxy.row return state.galaxy.row
.filter((c) => { .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 = {} } = {}) { function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {}) {
const p = P(state, seat); const p = P(state, seat);
const o = P(state, opp(seat)); const o = P(state, opp(seat));
if (!abilityGatePasses(state, seat, ab)) return false; if (!abilityGatePasses(state, seat, ab, ctx)) return false;
if (ab.cost?.exileFromHand) { const handNeeded = (ab.cost?.exileFromHand || 0) + (ab.cost?.discardFromHand || 0);
if (handNeeded) {
const spare = costPoolSize != null ? costPoolSize : p.hand.length; const spare = costPoolSize != null ? costPoolSize : p.hand.length;
if (spare < ab.cost.exileFromHand) return false; if (spare < handNeeded) return false;
} }
switch (ab.op) { switch (ab.op) {
case 'repairBase': return !!p.base && p.base.damage > 0; case 'repairBase': return !!p.base && p.base.damage > 0;
case 'oppDiscard': return o.hand.length > 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 '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 '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 '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': case 'draw': case 'gainResources': case 'gainForce': case 'gainAttack': case 'damageBase':
return true; return true;
default: { default: {
@ -288,7 +352,7 @@ function abilityUsable(state, seat, ab, { costPoolSize = null, ctx = {} } = {})
// chooseBase — pick your next base (start of turn after a loss) // chooseBase — pick your next base (start of turn after a loss)
// target — pick one candidate for a pending op (optional ⇒ null allowed) // target — pick one candidate for a pending op (optional ⇒ null allowed)
// oppDiscard — the *victim* picks which of their cards to discard // 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 // chooseOption— pick a sub-ability index for a 'choose' op
// Drop/refresh queue entries whose context changed since they were queued // Drop/refresh queue entries whose context changed since they were queued
// (a second forced discard after the hand emptied, a second exile after the // (a second forced discard after the hand emptied, a second exile after the
@ -297,6 +361,10 @@ function normalizeQueue(state) {
while (state.effectQueue.length) { while (state.effectQueue.length) {
const q = state.effectQueue[0]; const q = state.effectQueue[0];
if (q.kind === 'oppDiscard' && !P(state, q.seat).hand.length) { state.effectQueue.shift(); continue; } 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') { if (q.dynamic === 'exileCards') {
q.candidates = abilityCandidates(state, q.seat, { op: 'exileCards' }); q.candidates = abilityCandidates(state, q.seat, { op: 'exileCards' });
if (!q.candidates.length) { state.effectQueue.shift(); continue; } 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() }; 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 === '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 }; if (q.kind === 'chooseOption') return { kind: 'chooseOption', seat: q.seat, options: q.options };
return { kind: 'target', seat: q.seat, op: q.op, candidates: q.candidates, optional: !!q.optional, peekId: q.peekId ?? null }; return { kind: '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); 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; const ab = def.ability;
if (!ab || usedFlag) return; if (!ab || usedFlag) return;
if (ab.timing !== 'action') 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 }); 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.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); for (const e of p.capitals) tryAbility('capital', e.card.uid, cardDef(e.card), e.used, e);
if (p.base) { if (p.base) {
const b = baseDef(p.faction, p.base.id); const b = baseDef(p.faction, p.base.id);
if (b.ability?.timing === 'action' && !p.baseUsed && abilityUsable(state, seat, b.ability)) { 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 = {}) { function queueOp(state, seat, ab, ctx = {}) {
switch (ab.op) { switch (ab.op) {
case 'draw': { case 'draw': {
const p = P(state, seat);
let n = forceWith(state, seat) && ab.nForce != null ? ab.nForce : ab.n; 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); drawCards(state, seat, n);
return; return;
} }
@ -433,14 +503,12 @@ function queueOp(state, seat, ab, ctx = {}) {
} }
return; return;
} }
case 'spyChoice': { case 'oppChoice': {
const victim = opp(seat); const victim = opp(seat);
const options = []; const options = oppChoiceOptions(state, seat, ab, ctx);
if (P(state, victim).hand.length) options.push('discard');
if (!forcePegged(state, seat)) options.push('force');
if (!options.length) return; if (!options.length) return;
if (options.length === 1 && options[0] === 'force') { moveForce(state, seat, 1); return; } if (options.length === 1) { applyOppChoiceOption(state, seat, victim, options[0], ctx); return; }
state.effectQueue.push({ kind: 'spyChoice', seat: victim, bySeat: seat, options }); state.effectQueue.push({ kind: 'oppChoice', seat: victim, bySeat: seat, options, ctx });
return; return;
} }
case 'lookTop': { case 'lookTop': {
@ -452,6 +520,30 @@ function queueOp(state, seat, ab, ctx = {}) {
}); });
return; 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': { case 'revealTop': {
const top = state.galaxy.deck[state.galaxy.deck.length - 1]; const top = state.galaxy.deck[state.galaxy.deck.length - 1];
if (!top) return; if (!top) return;
@ -459,8 +551,7 @@ function queueOp(state, seat, ab, ctx = {}) {
emit(state, { type: 'reveal', seat, id: top.id }); emit(state, { type: 'reveal', seat, id: top.id });
const mine = P(state, seat).faction; const mine = P(state, seat).faction;
if (def.faction === mine) { if (def.faction === mine) {
P(state, seat).resources += 1; queueOp(state, seat, ab.onMine || { op: 'gainResources', n: 1 }, ctx);
emit(state, { type: 'resources', seat, n: 1 });
} else if (def.faction !== 'neutral') { } else if (def.faction !== 'neutral') {
state.effectQueue.push({ state.effectQueue.push({
kind: 'target', seat, op: 'revealTopDiscard', peekId: top.id, optional: false, kind: 'target', seat, op: 'revealTopDiscard', peekId: top.id, optional: false,
@ -469,8 +560,14 @@ function queueOp(state, seat, ab, ctx = {}) {
} }
return; return;
} }
case 'topdeckNextPurchase': {
P(state, seat).topdeckNext = true;
emit(state, { type: 'topdeckArmed', seat });
return;
}
case 'exileCards': { 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({ state.effectQueue.push({
kind: 'target', seat, op: 'exileCards', optional: ab.optional !== false, kind: 'target', seat, op: 'exileCards', optional: ab.optional !== false,
dynamic: 'exileCards', dynamic: 'exileCards',
@ -480,17 +577,28 @@ function queueOp(state, seat, ab, ctx = {}) {
return; return;
} }
case 'choose': { 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) return;
if (usable.length === 1) { queueOp(state, seat, usable[0], ctx); return; } if (usable.length === 1) { queueOp(state, seat, usable[0], ctx); return; }
state.effectQueue.push({ kind: 'chooseOption', seat, options: usable, ctx }); state.effectQueue.push({ kind: 'chooseOption', seat, options: usable, ctx });
return; 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); const candidates = abilityCandidates(state, seat, ab);
if (!candidates.length) return; if (!candidates.length) return;
state.effectQueue.push({ 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, candidates,
}); });
return; 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. // Direct base damage (card abilities): ignores capital ships.
function dealBaseDamage(state, seat, n, { direct = false } = {}) { function dealBaseDamage(state, seat, n, { direct = false } = {}) {
const o = P(state, opp(seat)); const o = P(state, opp(seat));
@ -521,14 +649,30 @@ function checkBaseDestroyed(state, attackerSeat) {
if (o.lostBases >= state.meta.basesToWin) endGame(state, attackerSeat, 'bases'); if (o.lostBases >= state.meta.basesToWin) endGame(state, attackerSeat, 'bases');
} }
function fireOnBounty(state, seat) { // "When X defeats a target in the galaxy row" — only the units that actually
const p = P(state, seat); // joined the winning attack trigger their onBounty abilities.
for (const e of [...p.inPlay, ...p.capitals]) { function fireOnBounty(state, seat, participants) {
for (const e of participants) {
const ab = cardDef(e.card).ability; 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 ──────────────────────────────────────────────────────────────── // ── Mutators ────────────────────────────────────────────────────────────────
export function actTurn(state, seat, action) { export function actTurn(state, seat, action) {
assertDecision(state, seat, 'turn'); 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' }); 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) { function useAbility(state, seat, action) {
@ -588,7 +732,7 @@ function useAbility(state, seat, action) {
ab = cardDef(entry.card).ability; ab = cardDef(entry.card).ability;
if (!ab || ab.timing !== 'action' || entry.used) throw new Error('ability unavailable'); 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 — // Pay costs first (rulebook: exileSelf cards keep their printed values —
// those were already collected at play time). // those were already collected at play time).
@ -599,6 +743,13 @@ function useAbility(state, seat, action) {
state.exile.push(c); state.exile.push(c);
emit(state, { type: 'exile', seat, id: c.id, from: 'hand', cost: true }); 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) { if (ab.cost?.exileSelf && entry) {
const pool = cardDef(entry.card).type === 'capital' ? p.capitals : p.inPlay; const pool = cardDef(entry.card).type === 'capital' ? p.capitals : p.inPlay;
pool.splice(pool.indexOf(entry), 1); pool.splice(pool.indexOf(entry), 1);
@ -630,8 +781,29 @@ function buyCard(state, seat, uid) {
p.resources -= cost; p.resources -= cost;
removeRowCard(state, uid); removeRowCard(state, uid);
} }
p.discard.push(card); placePurchased(state, seat, card, def);
emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: def.cost }); }
// 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) { 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.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.force) moveForce(state, seat, r.force);
if (r.draw) drawCards(state, seat, r.draw); 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) { 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 o.capitals) if (!order.includes(e)) order.push(e);
for (const e of order) { for (const e of order) {
if (pool <= 0) break; if (pool <= 0) break;
const hp = cardDef(e.card).hp; pool -= damageCapital(state, o.seat, e, pool);
const dealt = Math.min(pool, hp - e.damage);
e.damage += dealt;
pool -= dealt;
emit(state, { type: 'capitalDamage', seat: o.seat, uid: e.card.uid, n: dealt, hp, damage: e.damage });
if (e.damage >= hp) {
o.capitals.splice(o.capitals.indexOf(e), 1);
o.discard.push(e.card);
emit(state, { type: 'capitalDestroyed', seat: o.seat, id: e.card.id, uid: e.card.uid });
}
} }
if (pool > 0 && o.capitals.length === 0 && o.base) { if (pool > 0 && o.capitals.length === 0 && o.base) {
o.base.damage += pool; o.base.damage += pool;
@ -706,16 +871,31 @@ function attackBase(state, seat, action) {
function endTurn(state, seat) { function endTurn(state, seat) {
const p = P(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) { for (const e of p.inPlay) {
if (tempExile(e.card)) continue;
p.discard.push(e.card); p.discard.push(e.card);
emit(state, { type: 'endTurnDiscard', seat, uid: e.card.uid, id: e.card.id, zone: 'inPlay' }); emit(state, { type: 'endTurnDiscard', seat, uid: e.card.uid, id: e.card.id, zone: 'inPlay' });
} }
p.inPlay = []; p.inPlay = [];
for (const c of p.hand) { for (const c of p.hand) {
if (tempExile(c)) continue;
p.discard.push(c); p.discard.push(c);
emit(state, { type: 'endTurnDiscard', seat, uid: c.uid, id: c.id, zone: 'hand' }); emit(state, { type: 'endTurnDiscard', seat, uid: c.uid, id: c.id, zone: 'hand' });
} }
p.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; p.resources = 0;
drawCards(state, seat, state.meta.handSize); drawCards(state, seat, state.meta.handSize);
emit(state, { type: 'turnEnd', seat }); 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 }); emit(state, { type: 'buy', seat, id: card.id, uid: card.uid, cost: 0, free: true, topdeck: !!topdeck });
return; 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': { case 'lookTop': {
if (ref.v === 'discard') { if (ref.v === 'discard') {
const c = state.galaxy.deck.pop(); 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 }); emit(state, { type: 'discard', seat, id: c.id, forced: true });
} }
export function actSpyChoice(state, seat, choice) { export function actOppChoice(state, seat, idx) {
const d = assertDecision(state, seat, 'spyChoice'); const d = assertDecision(state, seat, 'oppChoice');
if (!d.options.includes(choice)) throw new Error(`spyChoice must be one of ${d.options}`); if (idx == null || idx < 0 || idx >= d.options.length) throw new Error('bad oppChoice index');
const q = state.effectQueue.shift(); const q = state.effectQueue.shift();
if (choice === 'force') { applyOppChoiceOption(state, q.bySeat, seat, q.options[idx], q.ctx || {});
moveForce(state, q.bySeat, 1);
} else {
// Victim still picks which card — queue their discard.
state.effectQueue.unshift({ kind: 'oppDiscard', seat });
}
} }
export function actChooseOption(state, seat, idx) { export function actChooseOption(state, seat, idx) {

View File

@ -2,36 +2,59 @@
Everything in SWDBG renders procedurally out of the box (faction-colored frame, 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 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 **no** art. This document lists the sprite sheets that replace the placeholder
replace the placeholder art windows, with exact dimensions, layout, and art windows, with exact dimensions, layout, and frame-by-frame maps.
frame-by-frame maps.
All art is wired through **`public/data/swdbg-artwork.json`**. The scene checks 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 whether each sheet's texture is loaded **and whether the mapped frame actually
it falls back to procedural drawing. You never need to touch code — just add a exists in the PNG** — a card mapped to a frame beyond the sheet's last row
PNG, set its `path` in that JSON (it's currently empty for both sheets), and simply stays procedural until you extend the image, so you can paint the new
reload. frames at your own pace.
Frame numbering is **row-major, 0-based**: frame 0 is top-left, count Frame numbering is **row-major, 0-based**: frame 0 is top-left, count
left-to-right then down to the next row. left-to-right then down to the next row.
Only paint the illustration — the card frame, border, faction edge color, 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 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 (no cropping), so match the aspect ratio below for edge-to-edge fill; other
aspects letterbox inside the window. 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` ## 1. Card art sheet — `swdbg-cards-art.png`
| | | | | |
|---|---| |---|---|
| **Path** | `public/assets/images/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) | | **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) | | **Sheet size** | **8 columns × 9 rows = 2400 × 1395 px** — 66 frames used (065), 6671 spare |
| **Status** | ⛔ Procedural until you create it (then set `cardSheet.path` in the JSON) | | **Status** | ✅ frames 054 painted (existing sheet) · ⛔ frames 5565 needed |
| **JSON** | `cardSheet` (set `path`) + `cards` map (already filled in for all 55 ids) | | **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 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 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 ### Frame map
Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim. Faction legend: **E** = Empire, **R** = Rebel, **N** = neutral/outer rim.
**Hero** cards get the procedural gold inset.
| Frame | id | Name | Faction | Notes | | Frame | id | Name | Faction | Notes |
|---:|---|---|---|---| |---:|---|---|---|---|
| 0 | `allianceshuttle` | Alliance Shuttle | R | | | 0 | `allianceshuttle` | Alliance Shuttle | R | starter |
| 1 | `rebeltrooper` | Rebel Trooper | R | | | 1 | `rebeltrooper` | Rebel Trooper | R | starter |
| 2 | `templeguardian` | Temple Guardian | R | | | 2 | `templeguardian` | Temple Guardian | R | starter |
| 3 | `imperialshuttle` | Imperial Shuttle | E | | | 3 | `imperialshuttle` | Imperial Shuttle | E | starter |
| 4 | `stormtrooper` | Stormtrooper | E | | | 4 | `stormtrooper` | Stormtrooper | E | starter |
| 5 | `inquisitor` | Inquisitor | E | | | 5 | `inquisitor` | Inquisitor | E | starter |
| 6 | `outerrimpilot` | Outer Rim Pilot | N | shared neutral card | | 6 | `outerrimpilot` | Outer Rim Pilot | N | shared neutral card |
| 7 | `tiefighter` | TIE Fighter | E | | | 7 | `tiefighter` | TIE Fighter | E | |
| 8 | `scouttrooper` | Scout Trooper | 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 | | | 11 | `tieinterceptor` | TIE Interceptor | E | |
| 12 | `atst` | AT-ST | E | | | 12 | `atst` | AT-ST | E | |
| 13 | `atat` | AT-AT | E | | | 13 | `atat` | AT-AT | E | |
| 14 | `generalveers` | General Veers | E | unique | | 14 | `generalveers` | General Veers | E | Hero |
| 15 | `grandmofftarkin` | Grand Moff Tarkin | E | unique | | 15 | `grandmofftarkin` | Grand Moff Tarkin | E | Hero |
| 16 | `directorkrennic` | Director Krennic | E | unique | | 16 | `directorkrennic` | Director Krennic | E | Hero |
| 17 | `darthvader` | Darth Vader | E | unique | | 17 | `darthvader` | Darth Vader | E | Hero |
| 18 | `emperorpalpatine` | Emperor Palpatine | E | unique | | 18 | — | *(orphaned: Emperor Palpatine)* | | card removed; art preserved, unused |
| 19 | `gozanticruiser` | Gozanti Cruiser | E | | | 19 | `gozanticruiser` | Gozanti Cruiser | E | capital |
| 20 | `imperialcarrier` | Imperial Carrier | E | | | 20 | `imperialcarrier` | Imperial Carrier | E | capital |
| 21 | `stardestroyer` | Star Destroyer | E | | | 21 | `stardestroyer` | Star Destroyer | E | capital |
| 22 | `rebelpathfinder` | Rebel Pathfinder | R | | | 22 | — | *(orphaned: Rebel Pathfinder)* | | card removed; art preserved, unused |
| 23 | `ywing` | Y-Wing | R | | | 23 | `ywing` | Y-Wing | R | |
| 24 | `snowspeeder` | Snowspeeder | R | | | 24 | `snowspeeder` | Snowspeeder | R | |
| 25 | `durosspy` | Duros Spy | R | | | 25 | `durosspy` | Duros Spy | R | |
| 26 | `xwing` | X-Wing | R | | | 26 | `xwing` | X-Wing | R | |
| 27 | `rebelcommando` | Rebel Commando | R | | | 27 | `rebelcommando` | Rebel Commando | R | |
| 28 | `cassianandor` | Cassian Andor | R | unique | | 28 | `cassianandor` | Cassian Andor | R | Hero |
| 29 | `k2so` | K-2SO | R | unique | | 29 | — | *(orphaned: K-2SO)* | | card removed; art preserved, unused |
| 30 | `chirrutimwe` | Chirrut Îmwe | R | unique | | 30 | `chirrutimwe` | Chirrut Îmwe | R | no longer a Hero (still 1-of) |
| 31 | `jynerso` | Jyn Erso | R | unique | | 31 | `jynerso` | Jyn Erso | R | Hero |
| 32 | `chewbacca` | Chewbacca | R | unique | | 32 | `chewbacca` | Chewbacca | R | Hero |
| 33 | `hansolo` | Han Solo | R | unique | | 33 | `hansolo` | Han Solo | R | Hero |
| 34 | `princessleia` | Princess Leia | R | unique | | 34 | `princessleia` | Princess Leia | R | Hero |
| 35 | `lukeskywalker` | Luke Skywalker | R | unique | | 35 | `lukeskywalker` | Luke Skywalker | R | Hero |
| 36 | `blockaderunner` | Blockade Runner | R | | | 36 | `blockaderunner` | Blockade Runner | **N** | capital — faction changed from Rebel |
| 37 | `hammerheadcorvette` | Hammerhead Corvette | R | | | 37 | `hammerheadcorvette` | Hammerhead Corvette | R | capital |
| 38 | `medicalfrigate` | Medical Frigate | R | | | 38 | — | *(orphaned: Medical Frigate)* | | card removed; art preserved, unused |
| 39 | `moncalamaricruiser` | Mon Calamari Cruiser | R | | | 39 | `moncalamaricruiser` | Mon Calamari Cruiser | R | capital |
| 40 | `millenniumfalcon` | Millennium Falcon | R | unique | | 40 | `millenniumfalcon` | Millennium Falcon | R | Hero — now a **unit**, not a capital |
| 41 | `z95headhunter` | Z-95 Headhunter | N | | | 41 | `z95headhunter` | Z-95 Headhunter | N | |
| 42 | `jawascavenger` | Jawa Scavenger | N | | | 42 | `jawascavenger` | Jawa Scavenger | N | |
| 43 | `keldormystic` | Kel Dor Mystic | 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 | | | 46 | `twileksmuggler` | Twi'lek Smuggler | N | |
| 47 | `fangfighter` | Fang Fighter | N | | | 47 | `fangfighter` | Fang Fighter | N | |
| 48 | `quarrenmercenary` | Quarren Mercenary | N | | | 48 | `quarrenmercenary` | Quarren Mercenary | N | |
| 49 | `bossk` | Bossk | N | unique bounty hunter | | 49 | `bossk` | Bossk | N | Hero |
| 50 | `ig88` | IG-88 | N | unique bounty hunter | | 50 | `ig88` | IG-88 | N | Hero |
| 51 | `bobafett` | Boba Fett | N | unique bounty hunter | | 51 | `bobafett` | Boba Fett | **E** | Hero — faction changed from neutral |
| 52 | `jabbathehutt` | Jabba the Hutt | N | unique | | 52 | `jabbathehutt` | Jabba the Hutt | N | Hero |
| 53 | `crocruiser` | Crolute Cruiser | N | | | 53 | `crocruiser` | C-ROC Cruiser | N | capital |
| 54 | `khetanna` | Khetanna (Jabba's Sail Barge) | N | unique | | 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 New-frame grid positions (300 × 155 px cells):
> show up every game (05, 79, 2224, 2627) read best painted first.
| 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` | | **Path** | `public/assets/images/swdbg-bases-art.png` |
| **Frame size** | **220 × 300 px** (aspect ≈ 0.733 : 1, portrait — matches the base art window) | | **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 | | **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) | | **Status** | ✅ painted — no changes needed (bases were not part of the card overhaul) |
| **JSON** | `baseSheet` (set `path`) + `bases` map (already filled in for all 10 ids) | | **JSON** | `baseSheet` + `bases` map (unchanged) |
Bases are the large planet/location cards planted on each player's side of the 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 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 ## 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`. - [ ] `swdbg-cards-art.png` — **extend the canvas from 2400×1085 to
- Priority 1 (uniques/leaders): 1418, 2835, 40, 4952, 54 2400×1395** (add 310 px at the bottom; existing art untouched).
- Priority 2 (common starters): 05, 79, 2224, 2627 - [ ] Paint the 11 new frames:
- Priority 3 (remaining Empire/Rebel commons): 1013, 1921, 3639 - [ ] 55 Lobot (old spare cell, row 6 col 7)
- Priority 4 (neutral/outer rim): 6, 4148, 53 - [ ] 56 Dengar · 57 Nebulon-B Frigate · 58 Lando Calrissian ·
- [ ] `swdbg-bases-art.png` — 1100×600 (5×2 grid), 220×300 per frame, 10 frames (09); then set `baseSheet.path` in `swdbg-artwork.json`. 59 Baze Malbus · 60 Rebel Transport · 61 U-Wing · 62 B-Wing ·
- [ ] `game-icons.png` frame 78 — 44×44 menu icon. 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 The frame→id mappings are already updated in `public/data/swdbg-artwork.json`
`public/data/swdbg-artwork.json` — once you drop the PNGs in and set the two — the new cards render procedurally today and pick up your art automatically
`path` fields, art appears with no code changes. 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. // verifySWDBG.js — headless checks for the Star Wars Deckbuilder engine.
// Run from repo root: node tools/verifySWDBG.js // Run from repo root: node tools/verifySWDBG.js
// Imports only Data/Logic/AI (never the Phaser scene). Card content is read // 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 { readFileSync } from 'node:fs';
import { import {
@ -10,7 +12,7 @@ import {
} from '../src/games/swdbg/SWDBGData.js'; } from '../src/games/swdbg/SWDBGData.js';
import { import {
newGame, makeRng, pendingDecision, takeEvents, publicView, legalActions, 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, entryAttack, forceWith, forcePegged, purchaseCost, isOver, FORCE_MAX,
} from '../src/games/swdbg/SWDBGLogic.js'; } from '../src/games/swdbg/SWDBGLogic.js';
import { decide } from '../src/games/swdbg/SWDBGAI.js'; import { decide } from '../src/games/swdbg/SWDBGAI.js';
@ -27,6 +29,20 @@ function ok(cond, msg) {
return false; 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 ──────────────────────────────────────────────────────── // ── 1. Content sanity ────────────────────────────────────────────────────────
console.log('Content sanity…'); console.log('Content sanity…');
{ {
@ -35,10 +51,12 @@ console.log('Content sanity…');
const checkAbility = (id, ab, allowed = TIMINGS) => { const checkAbility = (id, ab, allowed = TIMINGS) => {
if (!ab) return; if (!ab) return;
ok(allowed.includes(ab.timing || 'action') || ab.timing === undefined, `${id}: ability timing valid`); 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 opsIn(ab)) ok(OPS.includes(op), `${id}: op ${op} supported`);
for (const op of ops) ok(OPS.includes(op), `${id}: op ${op} supported`);
if (ab.timing === 'constant') ok(CONSTANT_OPS.includes(ab.op), `${id}: constant op is passive`); if (ab.timing === '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.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) { for (const def of json.cards) {
total += def.count; 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'); 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…'); console.log('Setup invariants…');
{ {
for (const faction of FACTIONS) { 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 'chooseBase': actChooseBase(st, d.seat, choice); break;
case 'target': actChooseTarget(st, d.seat, choice); break; case 'target': actChooseTarget(st, d.seat, choice); break;
case 'oppDiscard': actOppDiscard(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; case 'chooseOption': actChooseOption(st, d.seat, choice); break;
default: throw new Error(`unknown decision kind ${d.kind}`); default: throw new Error(`unknown decision kind ${d.kind}`);
} }
@ -167,7 +268,7 @@ function playGame(seed, skills, collect = null) {
return { st, tally, steps }; return { st, tally, steps };
} }
// ── 3. Determinism ─────────────────────────────────────────────────────────── // ── 4. Determinism ───────────────────────────────────────────────────────────
console.log('Determinism…'); console.log('Determinism…');
{ {
const logA = []; const logA = [];
@ -178,7 +279,7 @@ console.log('Determinism…');
`same seed reproduces the same event log (${logA.length} events)`); `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)…'); console.log('Self-play soak (300 games)…');
{ {
let games = 0; 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)`); 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…'); console.log('Rule spot-checks…');
function freshGame(seed = 31337) { function freshGame(seed = 31337) {
return newGame({ humanFaction: 'rebel', seed }); return newGame({ humanFaction: 'rebel', seed });
} }
function seatOf(st, faction) { return st.players.findIndex((p) => p.faction === faction); } function seatOf(st, faction) { return st.players.findIndex((p) => p.faction === faction); }
let giveUid = 900000;
function give(st, seat, id) { function give(st, seat, id) {
// Inject a known card into a player's play area for scripted checks. // 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 }; const entry = { card: inst, tempAttack: 0, used: false, committed: false };
if (cardDef(id).type === 'capital') { entry.damage = 0; st.players[seat].capitals.push(entry); } if (cardDef(id).type === 'capital') { entry.damage = 0; st.players[seat].capitals.push(entry); }
else st.players[seat].inPlay.push(entry); else st.players[seat].inPlay.push(entry);
return 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. // 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 st = freshGame(303);
const emp = seatOf(st, 'empire'); const reb = seatOf(st, 'rebel');
const reb = 1 - emp; const emp = 1 - reb;
st.turnSeat = emp; st.turnSeat = reb;
give(st, reb, 'moncalamaricruiser'); // big capital that would otherwise absorb const cap = give(st, emp, 'gozanticruiser'); // hp 3
const bomber = give(st, emp, 'tiebomber'); const y1 = give(st, reb, 'ywing');
st.force = -FORCE_MAX; // Force with the Empire → bomber deals 2 actTurn(st, reb, { type: 'ability', zone: 'play', uid: y1.card.uid });
const dmgBefore = st.players[reb].base.damage; ok(st.exile.some((c) => c.uid === y1.card.uid), 'Y-Wing exiles itself as the cost');
actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber.card.uid }); let d = pendingDecision(st);
ok(st.players[reb].base.damage === dmgBefore + 2, 'ability damage hits the base directly (Force-boosted)'); ok(d.kind === 'target' && d.op === 'dealDamage', 'Y-Wing queues a damage target choice');
ok(st.players[reb].capitals.length === 1, 'capital untouched by direct damage'); 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; // Base destruction wins at 3 lost bases.
st.players[reb].lostBases = 2; st.players[emp].base.damage = baseDef('empire', st.players[emp].base.id).hp - 1;
const bomber2 = give(st, emp, 'tiebomber'); st.players[emp].lostBases = 2;
actTurn(st, emp, { type: 'ability', zone: 'play', uid: bomber2.card.uid }); st.players[emp].capitals = [];
ok(st.over && st.winner === emp, 'third base destroyed ends the game immediately'); 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 st = freshGame(404);
const reb = seatOf(st, 'rebel'); const reb = seatOf(st, 'rebel');
st.turnSeat = reb; st.turnSeat = reb;
// Put a known Empire unit in the row. const target = plantRow(st, 0, 'landingcraft'); // target 4, reward 4 resources
const target = { uid: 888001, id: 'deathtrooper' }; // target 4, reward 2 resources
st.galaxy.row[0] = target;
const a1 = give(st, reb, 'rebelcommando'); // 3 attack const a1 = give(st, reb, 'rebelcommando'); // 3 attack
let threw = false; let threw = false;
try { actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid] }); } catch { threw = true; } 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; const resBefore = st.players[reb].resources;
actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [a1.card.uid, a2.card.uid] }); 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.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(st.galaxy.row.length === 6, 'row refilled after the bounty');
ok(a1.committed && a2.committed, 'bounty attackers are committed'); 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'); 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. // TIE Fighter: action ability gated on having a capital ship in play.
const st = freshGame(508); 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 st = freshGame(509);
const reb = seatOf(st, 'rebel'); const reb = seatOf(st, 'rebel');
const emp = 1 - reb; const emp = 1 - reb;
st.turnSeat = reb; st.turnSeat = reb;
const cassian = give(st, reb, 'cassianandor'); // 5 attack const cassian = give(st, reb, 'cassianandor'); // 5 attack
const target = { uid: 888002, id: 'tiefighter' }; // target 1 const target = plantRow(st, 0, 'tiefighter'); // target 1
st.galaxy.row[0] = target;
st.players[emp].hand = [{ uid: 'h1', id: 'stormtrooper' }]; st.players[emp].hand = [{ uid: 'h1', id: 'stormtrooper' }];
actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [cassian.card.uid] }); actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [cassian.card.uid] });
const d = pendingDecision(st); 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 st = freshGame(606);
const emp = seatOf(st, 'empire'); const emp = seatOf(st, 'empire');
st.force = -FORCE_MAX; st.force = -FORCE_MAX;
st.turnSeat = 1 - emp; // let empire's beginTurn run via endTurn st.turnSeat = 1 - emp; // let empire's beginTurn run via endTurn
actTurn(st, 1 - emp, { type: 'endTurn' }); actTurn(st, 1 - emp, { type: 'endTurn' });
ok(st.players[emp].resources >= 1, 'pegged Force grants a start-of-turn resource'); 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');
} }
{ {