Connect real Basic/Advanced base-faction audit setup to all seven Discovery Spice Cards and eight physical tokens. Add Great Maker's ordered majority vote and Fremen reserve ride, destructive Discovery blows, private faction peeks and Collection inspection/reveal, stash rewards and mandatory saved card overflow, and revealed nested board locations. Share private legal choices with human controls and all four AI profiles. Preserve normal gates. Independent review repairs orphaned worm/vote/ride controls, retargeted reserve arrivals, actor bindings and incomplete overflow discard receipts. Harden room-aware admission against unrevealed nested locations. Extend the backed-up local prototype starter with a Discovery profile; preserve seats. Still missing: next-turn free entry, five special location benefits and Ornithopter movement spending. Current prototype playthroughs do not certify those effects or complete module/expansion compliance. Verification: npm run check passed 4264 offline tests plus types/lint; npm run build passed; npm run test:integration passed 40 tests. The single source-bound broad run took 129358ms / 6782ms / 23918ms respectively. Report: /home/quintine/.local/share/dune/checkpoints/20260913-discovery-prototype/verification/report.json Source fingerprint: 4ea7b5209a58c369b0b1f8dc239b5dd2f3302bb759d83c32ba5406e80d8ae154 Two seeded Basic/Advanced games complete with component custody intact. SQLite races pay stashes once and resume committed discards without replay. Browser 3RJBH26Y verifies genuine human Fremen setup, a Discovery blow from one reordered physical card, native private Hiereg visibility, enlarged card/rules reading and refresh. Room remains paused at spice acceptance. All 125 opening saved rooms unchanged; 31 added, 156 total. No server restart. Efficiency: three reused bounded helpers; aggregate counter 60591430 to 61812513 after verification, 1221083 tokens before final Git/push overhead. 47 added offline tests. One stable broad run after focused review fixes; no calibration or exhaustive optional-module interaction matrix.
306 lines
8.5 KiB
TypeScript
306 lines
8.5 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()];
|
||
}
|
||
/** 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; 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';
|
||
}
|