feat(swdbg): rework TIE Fighter, Vader, Cassian, Luke; add capital gate & force SFX

- Add `requiresCapital` ability gate to card definitions
- Rework TIE Fighter: constant boost → action that draws a card if you control a capital ship
- Rework Darth Vader: constant +4 attack while Force is with you (removed damage ability)
- Rework Cassian Andor: onBounty trigger forces opponent discard on galaxy row kills
- Rework Luke Skywalker: onPlay destroy enemy capital ship if Force is with you
- Centralize ability requirement checks via `abilityGatePasses()` helper
- Apply gate checks to onBounty, onPlay, and onReveal triggers
- Add Force track shift SFX (light/dark variations) with randomized playback
- Update verification tests for new card behaviors
This commit is contained in:
Brian Fertig 2026-07-07 21:15:58 -06:00
parent 2287b3cafe
commit afd1681245
12 changed files with 83 additions and 21 deletions

BIN
assets/fx/sw-dark-1.mp3 Normal file

Binary file not shown.

BIN
assets/fx/sw-dark-2.mp3 Normal file

Binary file not shown.

BIN
assets/fx/sw-dark-3.mp3 Normal file

Binary file not shown.

BIN
assets/fx/sw-light-1.mp3 Normal file

Binary file not shown.

BIN
assets/fx/sw-light-2.mp3 Normal file

Binary file not shown.

BIN
assets/fx/sw-light-3.mp3 Normal file

Binary file not shown.

View File

@ -25,7 +25,7 @@
" 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? }", "ABILITY SHAPE: { timing, op, ...params, cost?, requiresForce?, requiresCapital? }",
" 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",
@ -33,6 +33,7 @@
" 'onReveal' = bases only: when the base is revealed", " 'onReveal' = bases only: when the base is revealed",
" cost { exileSelf: true } and/or { exileFromHand: 1 } paid before the op", " cost { exileSelf: true } and/or { exileFromHand: 1 } paid before the op",
" requiresForce: true = usable only while the Force is with you", " requiresForce: true = usable only while the Force is with you",
" requiresCapital: true = usable only while you have a capital ship 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):",
@ -111,9 +112,9 @@
}, },
"cards": [ "cards": [
{ "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 1, "attack": 1, "resources": 0, "force": 0, "count": 5, "target": 2, "reward": { "resources": 1 }, { "id": "tiefighter", "name": "TIE Fighter", "faction": "empire", "type": "unit", "unique": false, "traits": ["Fighter"], "cost": 2, "attack": 2, "resources": 0, "force": 0, "count": 5, "target": 1, "reward": { "resources": 1 },
"ability": { "timing": "constant", "op": "selfBoostWithTrait", "trait": "Fighter", "amount": 1 }, "ability": { "timing": "action", "op": "draw", "n": 1, "requiresCapital": true },
"text": "TIE Fighter gains 1 attack while you have another Fighter in play." }, "text": "If you have a capital ship in play, draw 1 card." },
{ "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1 }, { "id": "scouttrooper", "name": "Scout Trooper", "faction": "empire", "type": "unit", "unique": false, "traits": ["Trooper"], "cost": 2, "attack": 0, "resources": 2, "force": 0, "count": 3, "target": 3, "reward": { "resources": 1 },
"ability": { "timing": "action", "op": "revealTop" }, "ability": { "timing": "action", "op": "revealTop" },
"text": "Reveal the top card of the galaxy deck. If it is an Empire card, gain 1 resource. If it is a Rebel card, you may discard it." }, "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." },
@ -142,8 +143,8 @@
"ability": { "timing": "action", "op": "draw", "n": 1, "nBase": { "base": "deathstar", "n": 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)." }, "text": "Draw 1 card (2 cards instead if your base is the Death Star)." },
{ "id": "darthvader", "name": "Darth Vader", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, { "id": "darthvader", "name": "Darth Vader", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 },
"ability": { "timing": "action", "op": "damageBase", "n": 2, "requiresForce": true }, "ability": { "timing": "constant", "op": "selfBoostWhileForce", "amount": 4 },
"text": "If the Force is with you, deal 2 damage to the enemy base." }, "text": "Darth Vader gains 4 attack while the Force is with you." },
{ "id": "emperorpalpatine", "name": "Emperor Palpatine", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 3, "resources": 0, "force": 3, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 }, { "id": "emperorpalpatine", "name": "Emperor Palpatine", "faction": "empire", "type": "unit", "unique": true, "traits": ["Sith"], "cost": 8, "attack": 3, "resources": 0, "force": 3, "count": 1, "target": 9, "reward": { "resources": 3, "force": 3 },
"ability": { "timing": "action", "op": "choose", "options": [ { "op": "draw", "n": 2 }, { "op": "oppDiscard", "n": 1, "randomForce": true } ] }, "ability": { "timing": "action", "op": "choose", "options": [ { "op": "draw", "n": 2 }, { "op": "oppDiscard", "n": 1, "randomForce": true } ] },
"text": "Choose one: draw 2 cards, or your opponent discards a card from their hand (at random while the Force is with you)." }, "text": "Choose one: draw 2 cards, or your opponent discards a card from their hand (at random while the Force is with you)." },
@ -175,9 +176,9 @@
{ "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 }, { "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 }, "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)." }, "text": "Your opponent discards a card from their hand (at random while the Force is with you)." },
{ "id": "cassianandor", "name": "Cassian Andor", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Trooper", "Spy"], "cost": 4, "attack": 3, "resources": 1, "force": 0, "count": 1, "target": 5, "reward": { "resources": 2, "force": 1 }, { "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": "action", "op": "lookTop", "mayDiscard": true }, "ability": { "timing": "onBounty", "op": "oppDiscard", "n": 1 },
"text": "Look at the top card of the galaxy deck. You may discard it." }, "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 }, { "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 }, "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)." }, "text": "Your opponent discards a card from their hand (at random while the Force is with you)." },
@ -197,8 +198,8 @@
"ability": { "timing": "action", "op": "freePurchase", "faction": "rebel", "forceTopdeck": true }, "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." }, "text": "Purchase a Rebel card from the galaxy row for free. If the Force is with you, place that card on top of your deck." },
{ "id": "lukeskywalker", "name": "Luke Skywalker", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Jedi"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 8, "reward": { "resources": 3, "force": 3 }, { "id": "lukeskywalker", "name": "Luke Skywalker", "faction": "rebel", "type": "unit", "unique": true, "traits": ["Jedi"], "cost": 8, "attack": 6, "resources": 0, "force": 2, "count": 1, "target": 8, "reward": { "resources": 3, "force": 3 },
"ability": { "timing": "action", "op": "gainAttack", "n": 2, "requiresForce": true }, "ability": { "timing": "onPlay", "op": "destroyCapital", "requiresForce": true },
"text": "If the Force is with you, Luke Skywalker gains 2 attack." }, "text": "If the Force is with you, destroy an enemy capital ship." },
{ "id": "blockaderunner", "name": "Blockade Runner", "faction": "rebel", "type": "capital", "unique": false, "traits": ["Capital Ship"], "cost": 4, "attack": 1, "resources": 1, "force": 0, "hp": 4, "count": 2, "target": null, "reward": null, { "id": "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." },

View File

@ -13,7 +13,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, playForceMove, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { api } from '../../services/api.js'; import { api } from '../../services/api.js';
import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js'; import { createPlayerPortrait, createOpponentPortrait } from '../../ui/Portrait.js';
@ -2106,7 +2106,7 @@ export default class SWDBGGame extends Phaser.Scene {
this.showBanner(`${this.seatName(e.seat)} regroups at ${baseDef(this.gs.players[e.seat].faction, e.id).name}`.toUpperCase()); this.showBanner(`${this.seatName(e.seat)} regroups at ${baseDef(this.gs.players[e.seat].faction, e.id).name}`.toUpperCase());
break; break;
case 'forceMove': { case 'forceMove': {
this.sfx(SFX.SCIFI_PLINK ?? SFX.CARD_SHOW); playForceMove(this, e.to > e.from);
if (this._forceMarkerPos) this.shakeAt(this._forceMarkerPos.x, this._forceMarkerPos.y); if (this._forceMarkerPos) this.shakeAt(this._forceMarkerPos.x, this._forceMarkerPos.y);
break; break;
} }

View File

@ -97,6 +97,12 @@ 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) {
if (ab.requiresForce && !forceWith(state, seat)) return false;
if (ab.requiresCapital && P(state, seat).capitals.length === 0) return false;
return true;
}
function moveForce(state, seat, n) { function moveForce(state, seat, n) {
if (!n) return; if (!n) return;
const dir = forceSign(P(state, seat).faction); const dir = forceSign(P(state, seat).faction);
@ -255,7 +261,7 @@ function abilityCandidates(state, seat, ab, ctx = {}) {
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 (ab.requiresForce && !forceWith(state, seat)) return false; if (!abilityGatePasses(state, seat, ab)) return false;
if (ab.cost?.exileFromHand) { if (ab.cost?.exileFromHand) {
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 < ab.cost.exileFromHand) return false;
@ -519,7 +525,7 @@ function fireOnBounty(state, seat) {
const p = P(state, seat); const p = P(state, seat);
for (const e of [...p.inPlay, ...p.capitals]) { for (const e of [...p.inPlay, ...p.capitals]) {
const ab = cardDef(e.card).ability; const ab = cardDef(e.card).ability;
if (ab && ab.timing === 'onBounty') queueOp(state, seat, ab, { entry: e }); if (ab && ab.timing === 'onBounty' && abilityGatePasses(state, seat, ab)) queueOp(state, seat, ab, { entry: e });
} }
} }
@ -565,7 +571,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') queueOp(state, seat, def.ability, { entry }); if (def.ability?.timing === 'onPlay' && abilityGatePasses(state, seat, def.ability)) queueOp(state, seat, def.ability, { entry });
} }
function useAbility(state, seat, action) { function useAbility(state, seat, action) {
@ -728,7 +734,7 @@ export function actChooseBase(state, seat, baseId) {
state.effectQueue.shift(); state.effectQueue.shift();
emit(state, { type: 'baseChosen', seat, id: baseId }); emit(state, { type: 'baseChosen', seat, id: baseId });
const b = baseDef(p.faction, baseId); const b = baseDef(p.faction, baseId);
if (b.ability?.timing === 'onReveal') queueOp(state, seat, b.ability, {}); if (b.ability?.timing === 'onReveal' && abilityGatePasses(state, seat, b.ability)) queueOp(state, seat, b.ability, {});
} }
export function actChooseTarget(state, seat, ref) { export function actChooseTarget(state, seat, ref) {

View File

@ -149,6 +149,12 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-gem-drop', 'assets/fx/gem-drop.mp3'); this.load.audio('sfx-gem-drop', 'assets/fx/gem-drop.mp3');
this.load.audio('sfx-gem-big-drop','assets/fx/gem-big-drop.mp3'); this.load.audio('sfx-gem-big-drop','assets/fx/gem-big-drop.mp3');
this.load.audio('sfx-firework', 'assets/fx/firework.mp3'); this.load.audio('sfx-firework', 'assets/fx/firework.mp3');
this.load.audio('sfx-sw-light-1', 'assets/fx/sw-light-1.mp3');
this.load.audio('sfx-sw-light-2', 'assets/fx/sw-light-2.mp3');
this.load.audio('sfx-sw-light-3', 'assets/fx/sw-light-3.mp3');
this.load.audio('sfx-sw-dark-1', 'assets/fx/sw-dark-1.mp3');
this.load.audio('sfx-sw-dark-2', 'assets/fx/sw-dark-2.mp3');
this.load.audio('sfx-sw-dark-3', 'assets/fx/sw-dark-3.mp3');
this.load.spritesheet('catan-special-cards', 'assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); this.load.spritesheet('catan-special-cards', 'assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -63,12 +63,31 @@ export const SFX = {
GEM_DROP: 'sfx-gem-drop', GEM_DROP: 'sfx-gem-drop',
GEM_BIG_DROP: 'sfx-gem-big-drop', GEM_BIG_DROP: 'sfx-gem-big-drop',
FIREWORK: 'sfx-firework', FIREWORK: 'sfx-firework',
SW_LIGHT_1: 'sfx-sw-light-1',
SW_LIGHT_2: 'sfx-sw-light-2',
SW_LIGHT_3: 'sfx-sw-light-3',
SW_DARK_1: 'sfx-sw-dark-1',
SW_DARK_2: 'sfx-sw-dark-2',
SW_DARK_3: 'sfx-sw-dark-3',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {
try { scene.sound.play(key); } catch (_) {} try { scene.sound.play(key); } catch (_) {}
} }
// Picks and plays one of several keys at random (e.g. variations of the same cue).
export function playRandomSound(scene, keys) {
playSound(scene, keys[Math.floor(Math.random() * keys.length)]);
}
const SW_LIGHT_KEYS = [SFX.SW_LIGHT_1, SFX.SW_LIGHT_2, SFX.SW_LIGHT_3];
const SW_DARK_KEYS = [SFX.SW_DARK_1, SFX.SW_DARK_2, SFX.SW_DARK_3];
// SWDBG: Force track shifting toward the Rebel (light) or Empire (dark) side.
export function playForceMove(scene, towardLight) {
playRandomSound(scene, towardLight ? SW_LIGHT_KEYS : SW_DARK_KEYS);
}
// chip-bet is limited to one simultaneous instance. // chip-bet is limited to one simultaneous instance.
let _chipBetSound = null; let _chipBetSound = null;
export function playChipBet(scene) { export function playChipBet(scene) {

View File

@ -325,19 +325,49 @@ function give(st, seat, id) {
} }
{ {
// Constant boosts: Imperial Carrier aura + TIE swarm + Rodian row bonus. // Constant boosts: Imperial Carrier aura (Fighter trait) + Rodian row bonus.
const st = freshGame(505); const st = freshGame(505);
const emp = seatOf(st, 'empire'); const emp = seatOf(st, 'empire');
give(st, emp, 'imperialcarrier'); give(st, emp, 'imperialcarrier');
const tie1 = give(st, emp, 'tiefighter'); const tie1 = give(st, emp, 'tiefighter');
const tie2 = give(st, emp, 'tiefighter'); // TIE Fighter: 2 base + 1 carrier aura = 3
// TIE: 1 base + 1 swarm (another Fighter) + 1 carrier aura = 3 ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with carrier aura is 3 (got ${entryAttack(st, emp, tie1)})`);
ok(entryAttack(st, emp, tie1) === 3, `TIE Fighter attack with swarm+carrier is 3 (got ${entryAttack(st, emp, tie1)})`);
const rodian = give(st, emp, 'rodiangunslinger'); const rodian = give(st, emp, 'rodiangunslinger');
ok(entryAttack(st, emp, rodian) === 2, 'Rodian is 2 attack vs bases'); ok(entryAttack(st, emp, rodian) === 2, 'Rodian is 2 attack vs bases');
ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row'); ok(entryAttack(st, emp, rodian, { vsRow: true }) === 4, 'Rodian is 4 attack vs the galaxy row');
} }
{
// TIE Fighter: action ability gated on having a capital ship in play.
const st = freshGame(508);
const emp = seatOf(st, 'empire');
const tie = give(st, emp, 'tiefighter');
let threw = false;
try { actTurn(st, emp, { type: 'ability', zone: 'play', uid: tie.card.uid }); } catch { threw = true; }
ok(threw, 'TIE Fighter draw ability unavailable without a capital ship in play');
give(st, emp, 'gozanticruiser');
const handBefore = st.players[emp].hand.length;
actTurn(st, emp, { type: 'ability', zone: 'play', uid: tie.card.uid });
ok(st.players[emp].hand.length === handBefore + 1, 'TIE Fighter draws a card once a capital ship is in play');
}
{
// Cassian Andor: onBounty trigger forces an opponent discard.
const st = freshGame(509);
const reb = seatOf(st, 'rebel');
const emp = 1 - reb;
st.turnSeat = reb;
const cassian = give(st, reb, 'cassianandor'); // 5 attack
const target = { uid: 888002, id: 'tiefighter' }; // target 1
st.galaxy.row[0] = target;
st.players[emp].hand = [{ uid: 'h1', id: 'stormtrooper' }];
actTurn(st, reb, { type: 'attackRow', targetUid: target.uid, uids: [cassian.card.uid] });
const d = pendingDecision(st);
ok(d.kind === 'oppDiscard' && d.seat === emp, "Cassian Andor's onBounty queues a forced opponent discard");
actOppDiscard(st, emp, 'h1');
ok(st.players[emp].hand.length === 0, 'the forced discard resolves');
}
{ {
// Force mechanics: pegged bonus resource, spy choice restrictions. // Force mechanics: pegged bonus resource, spy choice restrictions.
const st = freshGame(606); const st = freshGame(606);