Files
Dune/game/leader-skills.ts
T
Quintine 6e6fecc9d4 Prototype Leader Skills setup, battle disciplines and capture
Connect all fourteen physical skills and private setup to five card-role battle
modifiers, public posture, Harkonnen skilled capture, death return and optional
own-leader revival through controls, legal AI and persisted room actions.
Nine skill effects and combined-mode/publication acceptance remain unfinished.
Captured replacement entitlement and foreign ghola integration stay guarded.

Fresh publisher/designer research supports skilled-capture publicity through
its unique known physical card; this is recorded as a component inference.
Independent review found and fixed Voice/must-use-leader and invalid saved
assignment/offer cases. A pre-existing Discovery privacy assertion confused
card ID prefixes; the corrected deterministic physical-pair scenario was
independently reviewed. The first broad run was 4410/4411; corrected final
verification passes types/lint, all 4411 offline tests, build and 43 HTTP tests.
Final durations: check 149741ms, build 6659ms, integration 28274ms.
Source fingerprint: aef655ac1ebc0eb2f64294e13d3d27337167312cf8ab17568a166ee9c39f6a85.
Private final report: 20260913-leader-skills/checks-final/report.json.

Three seeded prototype games completed with no rejected bot candidates. Browser
setup/card inspection/refresh passed. Another user-reported power outage stopped
the server; backup and necessary restart restored the same private seat. All
292 games at restart and their original application records remain unchanged;
33 isolated HTTP-test rooms bring the current total to 325. No database reset.

Efficiency: one shared setup/lifecycle implementation supports five connected
role effects; selective existing agents handled catalog/UI/tests/research and
independent reviews. One repeated broad check was required by the reproduced
ID-prefix assertion failure. Aggregate goal counter rose from 65529766 to
67313673 through this checkpoint (1783907), including inherited context and
coordination; this is not a billing or savings estimate. Full goal remains active.
2026-09-13 17:31:37 +10:00

281 lines
8.4 KiB
TypeScript

import { LEADER_SKILL_CARDS, type LeaderSkillId } from './leader-skill-cards';
export type LeaderSkillOffer = {
event: string;
/** An empty revival offer is the choice whether to draw, not a free preview. */
cards: LeaderSkillId[];
/** Revival fixes the eligible leader; setup lets the owner choose. */
leader: string | null;
};
export type LeaderSkillAssignment = {
skill: LeaderSkillId;
leader: string;
owner: string;
};
export type LeaderSkillsState = {
deck: LeaderSkillId[];
offers: Record<string, LeaderSkillOffer>;
assignments: LeaderSkillAssignment[];
};
export type LeaderSkillsView = {
assignments: (LeaderSkillAssignment & {
controller: string;
captured: boolean;
faceUp: boolean;
})[];
offer: LeaderSkillOffer | null;
eligibleLeaders: { id: string; name: string }[];
/** Only this player's genuine public choice is included. */
battleChoice: { event: string; leader: string; skill: LeaderSkillId } | null;
};
export type SkillLeader = {
id: string;
dead: boolean;
capturedBy?: string;
gholaBy?: string;
};
export type SkillRoster = readonly {
id: string;
leaders: readonly SkillLeader[];
}[];
export class LeaderSkillError extends Error {
constructor(message: string) {
super(message);
this.name = 'LeaderSkillError';
}
}
function ensure(condition: unknown, message: string): asserts condition {
if (!condition) throw new LeaderSkillError(message);
}
function shuffled<T>(cards: readonly T[], random: () => number): T[] {
const result = [...cards];
for (let i = result.length - 1; i > 0; i--) {
const roll = random();
ensure(
Number.isFinite(roll) && roll >= 0 && roll < 1,
'Invalid skill shuffle.',
);
const j = Math.floor(roll * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function createLeaderSkills(random: () => number): LeaderSkillsState {
return {
deck: shuffled(
LEADER_SKILL_CARDS.map((card) => card.id),
random,
),
offers: {},
assignments: [],
};
}
/** Validate physical custody without treating a hidden skill as a new copy. */
export function validateLeaderSkills(
state: LeaderSkillsState,
roster: SkillRoster,
): void {
ensure(
state &&
Array.isArray(state.deck) &&
Array.isArray(state.assignments) &&
state.offers &&
typeof state.offers === 'object' &&
!Array.isArray(state.offers),
'Invalid saved Leader Skills state.',
);
const cards: string[] = [...state.deck];
const assignedLeaders = new Set<string>();
const originalOwners = new Set<string>();
for (const assignment of state.assignments) {
const owner = roster.find((p) => p.id === assignment.owner);
ensure(
owner?.leaders.some((leader) => leader.id === assignment.leader),
'A skill must remain attached to its original leader.',
);
ensure(
!assignedLeaders.has(assignment.leader),
'A leader cannot hold two skills.',
);
assignedLeaders.add(assignment.leader);
ensure(
!originalOwners.has(assignment.owner),
'A faction cannot acquire a second original skill while the captured replacement ruling is unresolved.',
);
originalOwners.add(assignment.owner);
cards.push(assignment.skill);
}
for (const [owner, offer] of Object.entries(state.offers)) {
ensure(
!originalOwners.has(owner),
'A faction with an assigned skill cannot hold another skill offer.',
);
ensure(
roster.some((p) => p.id === owner) &&
offer &&
typeof offer.event === 'string' &&
offer.event.length > 0 &&
Array.isArray(offer.cards) &&
(offer.cards.length === 2 ||
(offer.leader !== null && offer.cards.length === 0)),
'Invalid saved skill choice.',
);
ensure(
offer.leader === null ||
roster
.find((p) => p.id === owner)
?.leaders.some((l) => l.id === offer.leader),
'Invalid revival skill leader.',
);
cards.push(...offer.cards);
}
const canonical = LEADER_SKILL_CARDS.map((c) => c.id);
ensure(
cards.length === canonical.length &&
new Set(cards).size === canonical.length &&
canonical.every((id) => cards.includes(id)),
'Leader Skills must preserve all fourteen physical cards exactly once.',
);
}
/** Deal every initial two-card hand before any rejected card returns to the deck. */
export function dealLeaderSkills(
state: LeaderSkillsState,
players: readonly string[],
event: string,
): LeaderSkillsState {
ensure(
!state.assignments.length &&
!Object.keys(state.offers).length &&
state.deck.length === LEADER_SKILL_CARDS.length,
'Leader Skills have already been dealt.',
);
ensure(
players.length >= 2 &&
players.length <= 6 &&
new Set(players).size === players.length &&
!!event,
'Choose two through six distinct skill recipients.',
);
const next = structuredClone(state);
for (const player of players)
next.offers[player] = {
event,
cards: next.deck.splice(0, 2),
leader: null,
};
return next;
}
/** Callers supply the living own leader choices after excluding Auditor/captives. */
export function chooseLeaderSkill(
state: LeaderSkillsState,
owner: string,
event: string,
skill: string,
leader: string,
eligibleLeaders: readonly string[],
random: () => number,
): LeaderSkillsState {
const offer = state.offers[owner];
ensure(
!state.assignments.some((assignment) => assignment.owner === owner),
'This faction already has an assigned Leader Skill.',
);
ensure(
offer &&
offer.event === event &&
offer.cards.includes(skill as LeaderSkillId),
'Choose one of your current Leader Skill cards.',
);
ensure(
eligibleLeaders.includes(leader) &&
(!offer.leader || offer.leader === leader),
'Choose an eligible living leader for this skill.',
);
ensure(
!state.assignments.some((assignment) => assignment.leader === leader),
'This leader already has a skill.',
);
const next = structuredClone(state);
next.assignments.push({ owner, leader, skill: skill as LeaderSkillId });
next.deck = shuffled(
[...next.deck, ...offer.cards.filter((card) => card !== skill)],
random,
);
delete next.offers[owner];
return next;
}
/** Death returns the physical card once; capture leaves it attached to the disc. */
export function returnDeadLeaderSkills(
state: LeaderSkillsState,
roster: SkillRoster,
random: () => number,
): { state: LeaderSkillsState; returned: LeaderSkillAssignment[] } {
const returned = state.assignments.filter(
(assignment) =>
roster
.find((p) => p.id === assignment.owner)
?.leaders.find((l) => l.id === assignment.leader)?.dead,
);
if (!returned.length) return { state, returned };
const next = structuredClone(state);
next.assignments = next.assignments.filter(
(assignment) => !returned.some((dead) => dead.leader === assignment.leader),
);
next.deck = shuffled(
[...next.deck, ...returned.map((assignment) => assignment.skill)],
random,
);
return { state: next, returned };
}
/** The capture/replacement question stays guarded while an original skill exists. */
export function offerRevivedLeaderSkill(
state: LeaderSkillsState,
owner: string,
leader: string,
event: string,
): LeaderSkillsState {
if (
state.assignments.some((assignment) => assignment.owner === owner) ||
state.offers[owner]
)
return state;
ensure(
state.deck.length >= 2 && !!event,
'Two physical cards are required for a revived leader skill choice.',
);
const next = structuredClone(state);
next.offers[owner] = { event, cards: [], leader };
return next;
}
export function drawRevivedLeaderSkills(
state: LeaderSkillsState,
owner: string,
event: string,
): LeaderSkillsState {
const offer = state.offers[owner];
ensure(
offer?.leader &&
offer.event === event &&
offer.cards.length === 0 &&
state.deck.length >= 2,
'Only a current undrawn revival offer may draw skill cards.',
);
const next = structuredClone(state);
next.offers[owner].cards = next.deck.splice(0, 2);
return next;
}
export function declineRevivedLeaderSkill(
state: LeaderSkillsState,
owner: string,
event: string,
): LeaderSkillsState {
const offer = state.offers[owner];
ensure(
offer?.leader && offer.event === event && offer.cards.length === 0,
'Only an undrawn revival skill offer may be declined.',
);
const next = structuredClone(state);
delete next.offers[owner];
return next;
}