Files
Dune/game/cards.ts
T
Quintine 788453e07e Integrate Smuggler companion with owned Richese No-Field
Connect the GF9 FAQ combination: one free physical reserve force accompanies an owned concealed No-Field at its unchanged one-marker price. Preserve zero/three/five token values, actual supply at later reveal, optional human choice and all four legal AI paths. Genuine CHOAM & Richese Leader Skills development setup adds the two sourced battle cards without duplicating their Ix identities. Public mode and publication gates stay closed; allied and remaining combined routes are unfinished.

Bind the private declaration across initial No-Field cancellation and later Advanced Guild interception, including saved interruptions. Validate before projection/actions/normalization, hide the internal proof, reject altered declarations immutably, and withdraw the companion once only on successful commitment. Independent rules/privacy/persistence review found no blocker. Preserve other audit setup gates despite expanded deck assembly. Repair the existing randomized recovery test with a genuine seeded Rihani offer and explicit human choice, replacing an inappropriate AI-policy call on a human seat. Initial Karama and later Guild stop retain their distinct existing opportunity outcomes.

Validation: 72 focused regressions plus 10 deck tests; typecheck, lint, 4520 offline tests, production build and 43 HTTP tests passed. SQLite restart, concurrent allowance/cancellation and exact physical custody passed. Browser genuine assignment, private token selection, opt-in, one-spice shipment, refresh, five-force reveal and final refresh preserved six physical Arrakeen forces, 14 reserves, zero Tanks, four spice, Crysknife and Soo-Soo Sook. Genuine four-profile Basic/Advanced games completed in 77/116 actions with 3/4 JSON restores and no rejected candidates; Advanced exercised one companion shipment and later reveal.

After the outage the existing server was healthy. Preserved all 529 opening rooms and original seat/recovery records (563 after QA/HTTP). SQLite integrity and foreign keys passed. No restart, database reset or automation.

Verified source tree: c2aee4138bcb46a05814a340d62eadace3470247ca620844eb0375e78ea4d6c4
2026-09-13 21:37:50 +10:00

311 lines
8.9 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()];
}
/** CHOAM & Richese p. 4 replaces these Ix cards, or adds them when Ix is absent.
* Preserve one physical identity per card across either expansion combination. */
export function choamDeck(): Card[] {
return ixBattleCards().filter(c => c.kind === 'poisonTooth' || c.kind === 'artillery');
}
/** Deck assembly does not open the separate faction/module release gates. */
export function treacheryDeck(expansions: readonly string[] = []): Card[] {
if (expansions.some((id) => id !== 'ix' && id !== 'choam'))
throw new Error('This expansion treachery deck is not implemented yet.');
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : expansions.includes('choam') ? choamDeck() : [])];
}
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; discovery?: string }
| { worm: true; suppressed?: true; thumper?: true; greatMaker?: 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';
}