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.
281 lines
8.4 KiB
TypeScript
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;
|
|
}
|