Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed. Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation. Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh. Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias. After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication. Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
313 lines
9.0 KiB
TypeScript
313 lines
9.0 KiB
TypeScript
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) => !['ix', 'choam', 'ecaz'].includes(id)))
|
||
throw new Error('This expansion treachery deck is not implemented yet.');
|
||
// Ecaz/Moritani factions are independent of the optional three-card variant.
|
||
// Selecting those factions does not activate its still-unimplemented effects.
|
||
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';
|
||
}
|