Files
Dune/game/cards.ts
T
2026-09-08 07:37:52 +10:00

306 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FactionId } from './catalog';
export type Card = {
id: string;
name: string;
kind:
| 'projectile'
| 'poison'
| 'lasgun'
| 'shield'
| 'snooper'
| 'poisonBlade'
| 'shieldSnooper'
| 'weirdingWay'
| 'chemistry'
| 'poisonTooth'
| 'artillery'
| 'worthless'
| 'hero'
| 'special';
effect?: string;
};
/** Implemented Ix battle cards; the complete expansion deck is assembled separately. */
export function ixBattleCards(): Card[] {
return [
{ id: 'ix-poison-tooth', name: 'Poison Tooth', kind: 'poisonTooth' },
{ id: 'ix-artillery', name: 'Artillery Strike', kind: 'artillery' },
{ id: 'ix-poison-blade', name: 'Poison Blade', kind: 'poisonBlade' },
{ id: 'ix-shield-snooper', name: 'Shield Snooper', kind: 'shieldSnooper' },
{ id: 'ix-weirding-way', name: 'Weirding Way', kind: 'weirdingWay' },
{ id: 'ix-chemistry', name: 'Chemistry', kind: 'chemistry' },
];
}
export function ixSpecialCards(): Card[] {
return [
{ id: 'ix-thumper', name: 'Thumper', kind: 'special', effect: 'thumper' },
{ id: 'ix-amal', name: 'Amal', kind: 'special', effect: 'amal' },
];
}
/** Remaining Ix cards use the ordinary base-game rule types, with separate physical identities. */
export function ixStandardCards(): Card[] {
return [
{ id: 'ix-hunter-seeker', name: 'Hunter Seeker', kind: 'projectile' },
{ id: 'ix-basilia-weapon', name: 'Basilia Weapon', kind: 'poison' },
{ id: 'ix-shield', name: 'Shield', kind: 'shield' },
{ id: 'ix-snooper', name: 'Snooper', kind: 'snooper' },
{
id: 'ix-harvester',
name: 'Harvester',
kind: 'special',
effect: 'harvester',
},
{ id: 'ix-kull-wahad', name: 'Kull Wahad', kind: 'worthless' },
];
}
export function ixDeck(): Card[] {
return [...ixBattleCards(), ...ixSpecialCards(), ...ixStandardCards()];
}
/** Complete currently implemented deck sets. Other expansion decks remain gated. */
export function treacheryDeck(expansions: readonly string[] = []): Card[] {
if (expansions.some((id) => id !== 'ix'))
throw new Error('This expansion treachery deck is not implemented yet.');
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : [])];
}
export type Leader = {
/** Explicit projected custody for a shared disc; null means set aside. */
controller?: string | null;
id: string;
name: string;
strength: number;
faction: FactionId;
dead: boolean;
deaths: number;
usedAt?: string;
capturedBy?: string;
gholaBy?: string;
concealed?: {
captor: string;
controller?: string;
dead: boolean;
deaths: number;
usedAt?: string;
};
};
const leaderLists: Partial<Record<FactionId, [string, number][]>> = {
atreides: [
['Thufir Hawat', 5],
['Lady Jessica', 5],
['Gurney Halleck', 4],
['Duncan Idaho', 2],
['Dr. Yueh', 1],
],
harkonnen: [
['Feyd-Rautha', 6],
['Beast Rabban', 4],
['Piter de Vries', 3],
['Captain Iakin Nefud', 2],
['Umman Kudu', 1],
],
emperor: [
['Hasimir Fenring', 6],
['Captain Aramsham', 5],
['Caid', 3],
['Burseg', 3],
['Bashar', 2],
],
fremen: [
['Stilgar', 7],
['Chani', 6],
['Otheym', 5],
['Shadout Mapes', 3],
['Jamis', 2],
],
guild: [
['Staban Tuek', 5],
['Master Bewt', 3],
['Esmar Tuek', 3],
['Soo-Soo Sook', 2],
['Guild Representative', 1],
],
tleilaxu: [
['Zoal', 3],
['Hidar Fen Ajidica', 4],
['Master Zaaf', 3],
['Wykk', 2],
['Blin', 1],
],
ixians: [
['Dominic Vernius', 4],
['C’tair Pilru', 5],
['Tessia Vernius', 5],
['Kailea Vernius', 2],
['Cammar Pilru', 1],
],
// Printed ordinary discs: CHOAM & Richese rulebook p. 3; see docs/CHOAM_RICHESE_LEADERS.md.
// The strength-2 Auditor is an additional advanced disc, outside CHOAM's ordinary five.
choam: [
['Frankos Aru', 4],
['Lady Jalma', 4],
['Rajiv Londine', 3],
['Duke Verdun', 3],
['Viscount Tull', 2],
],
richese: [
['Ein Calimar', 5],
['Lady Helena', 4],
['Flinto Kinnis', 3],
['Haloa Rund', 2],
['Talis Balt', 2],
],
// Printed ordinary discs: Ecaz & Moritani rulebook p. 3; see docs/MORITANI_TERROR_RULES.md.
// Duke Prad Vidal is a separate shared disc with no traitor card, not part of this roster.
ecaz: [
['Sanya Ecaz', 4],
['Whitmore Bludd', 4],
['Ilesa Ecaz', 3],
['Rivvy Dinari', 3],
['Bindikk Narvi', 2],
],
moritani: [
['Lupino Ord', 5],
['Grieu Kronos', 4],
['Hiih Resser', 4],
['Trin Kronos', 2],
['Vando Terboli', 1],
],
beneGesserit: [
['Alia', 5],
['Wanna Marcus', 5],
['Princess Irulan', 5],
['Reverend Mother Ramallo', 5],
['Margot Lady Fenring', 5],
],
};
export function leaders(f: FactionId): Leader[] {
return (leaderLists[f] ?? []).map(([name, strength], i) => ({
id: `${f}-${i}`,
name,
strength,
faction: f,
dead: false,
deaths: 0,
}));
}
/** Extra Advanced CHOAM disc; printed CHOAM & Richese rulebook pp. 3 and 8. */
export const CHOAM_AUDITOR_ID = 'choam-auditor';
export function isAuditorLeader(leader: { id: string }): boolean {
return leader.id === CHOAM_AUDITOR_ID;
}
/** Kept separate from the five ordinary discs and their ordinary setup pool. */
export function createAuditorLeader(): Leader {
return {
id: CHOAM_AUDITOR_ID,
name: 'Auditor',
strength: 2,
faction: 'choam',
dead: false,
deaths: 0,
};
}
export function baseDeck(): Card[] {
const cards: Omit<Card, 'id'>[] = [];
const add = (
name: string,
kind: Card['kind'],
count = 1,
effect?: string,
) => {
for (let i = 0; i < count; i++)
cards.push({ name, kind, ...(effect ? { effect } : {}) });
};
['Crysknife', 'Maula Pistol', 'Slip Tip', 'Stunner'].forEach((n) =>
add(n, 'projectile'),
);
['Chaumas', 'Chaumurky', 'Gom Jabbar', 'Ellaca Drug'].forEach((n) =>
add(n, 'poison'),
);
add('Lasgun', 'lasgun');
add('Shield', 'shield', 4);
add('Snooper', 'snooper', 4);
['Baliset', 'Jubba Cloak', 'Kulon', 'La La La', 'Trip to Gamont'].forEach(
(n) => add(n, 'worthless'),
);
add('Cheap Hero', 'hero');
add('Cheap Heroine', 'hero');
[
['Family Atomics', 'atomics'],
['Weather Control', 'weather'],
['Hajr', 'hajr'],
['Tleilaxu Ghola', 'ghola'],
['Harvester', 'harvester'],
].forEach(([n, e]) => add(n, 'special', 1, e));
add('Karama', 'special', 2, 'karama');
add('Truthtrance', 'special', 2, 'truthtrance');
return cards.map((c, i) => ({ ...c, id: `treachery-${i}` }));
}
export const SPICE_CARDS = [
['broken_land', 8, 12],
['red_chasm', 8, 7],
['habbanya_erg', 8, 16],
['oh_gap', 6, 10],
['wind_pass_north', 6, 17],
['south_mesa', 10, 5],
['rock_outcroppings', 6, 13],
['the_minor_erg', 8, 8],
['cielago_south', 12, 2],
['cielago_north', 8, 3],
['funeral_plain', 6, 15],
['the_great_flat', 10, 15],
['hagga_basin', 6, 12],
['habbanya_ridge_flat', 10, 18],
['sihaya_ridge', 6, 9],
] as const;
export type SpiceCard =
| { territory: string; amount: number; sector: number }
| { worm: true; suppressed?: true; thumper?: true }
| { sandtrout: true };
export function spiceDeck(includeSandtrout = false): SpiceCard[] {
return [
...SPICE_CARDS.map(([territory, amount, sector]) => ({
territory,
amount,
sector,
})),
...Array.from({ length: 6 }, () => ({ worm: true as const })),
...(includeSandtrout ? [{ sandtrout: true as const }] : []),
];
}
/** Zoal's printed X copies the opposing disc, excluding KH. His ordinary revival value is three. */
export function battleLeaderStrength(
leader: Pick<Leader, 'id' | 'strength'> | undefined,
opponent: Pick<Leader, 'strength'> | undefined,
) {
return leader?.id === 'tleilaxu-0'
? (opponent?.strength ?? 0)
: (leader?.strength ?? 0);
}
export function leaderStrengthLabel(leader: Pick<Leader, 'id' | 'strength'>) {
return leader.id === 'tleilaxu-0' ? 'X' : String(leader.strength);
}
/** Old persisted windows use `harvested` for either a played card or destroyed fresh spice. */
export function harvesterAvailable(
blow:
| {
harvested: boolean;
harvesters?: number;
harvesterClosed?: boolean;
}
| null
| undefined,
): boolean {
return (
!!blow &&
!blow.harvesterClosed &&
(!blow.harvested || (blow.harvesters ?? 0) > 0)
);
}
export function normalizeLegacyCardNames(cards: Card[]) {
for (const card of cards)
if (card.id === 'treachery-7' && card.name === 'Basilia Weapon')
card.name = 'Ellaca Drug';
}