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.
415 lines
11 KiB
TypeScript
415 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
LEADER_SKILL_CARDS,
|
|
type LeaderSkillId,
|
|
} from '../game/leader-skill-cards';
|
|
import {
|
|
LeaderSkillError,
|
|
chooseLeaderSkill,
|
|
createLeaderSkills,
|
|
dealLeaderSkills,
|
|
declineRevivedLeaderSkill,
|
|
drawRevivedLeaderSkills,
|
|
offerRevivedLeaderSkill,
|
|
returnDeadLeaderSkills,
|
|
validateLeaderSkills,
|
|
type LeaderSkillsState,
|
|
type SkillRoster,
|
|
} from '../game/leader-skills';
|
|
|
|
const KEEP_ORDER = () => 0.999999;
|
|
|
|
function playerIds(count: number): string[] {
|
|
return Array.from({ length: count }, (_, index) => `player-${index + 1}`);
|
|
}
|
|
|
|
function rosterFor(players: readonly string[]): SkillRoster {
|
|
return players.map((id) => ({
|
|
id,
|
|
leaders: [
|
|
{ id: `${id}-leader-1`, dead: false },
|
|
{ id: `${id}-leader-2`, dead: false },
|
|
],
|
|
}));
|
|
}
|
|
|
|
function dealtState(players = ['a', 'b'] as const): LeaderSkillsState {
|
|
return dealLeaderSkills(createLeaderSkills(KEEP_ORDER), players, 'setup-1');
|
|
}
|
|
|
|
function expectImmutableRejection(
|
|
state: LeaderSkillsState,
|
|
action: () => unknown,
|
|
message: RegExp,
|
|
): void {
|
|
const snapshot = structuredClone(state);
|
|
assert.throws(action, (error: unknown) => {
|
|
assert.ok(error instanceof LeaderSkillError);
|
|
assert.match(error.message, message);
|
|
return true;
|
|
});
|
|
assert.deepEqual(state, snapshot);
|
|
}
|
|
|
|
void test('deals every two-card setup hand before any card can return for two through six players', () => {
|
|
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
|
|
|
|
for (let count = 2; count <= 6; count += 1) {
|
|
const players = playerIds(count);
|
|
const pristine = createLeaderSkills(KEEP_ORDER);
|
|
const snapshot = structuredClone(pristine);
|
|
const dealt = dealLeaderSkills(pristine, players, `setup-${count}`);
|
|
|
|
assert.deepEqual(pristine, snapshot);
|
|
assert.equal(Object.keys(dealt.offers).length, count);
|
|
assert.equal(dealt.deck.length, 14 - 2 * count);
|
|
assert.deepEqual(dealt.assignments, []);
|
|
assert.deepEqual(
|
|
players.flatMap((player) => dealt.offers[player].cards),
|
|
canonical.slice(0, 2 * count),
|
|
);
|
|
for (const player of players) {
|
|
assert.deepEqual(dealt.offers[player], {
|
|
event: `setup-${count}`,
|
|
cards: canonical.slice(
|
|
players.indexOf(player) * 2,
|
|
players.indexOf(player) * 2 + 2,
|
|
),
|
|
leader: null,
|
|
});
|
|
}
|
|
validateLeaderSkills(dealt, rosterFor(players));
|
|
}
|
|
});
|
|
|
|
void test('chooses one physical card once, returns the rejected card, and preserves all fourteen cards', () => {
|
|
const before = dealtState();
|
|
const snapshot = structuredClone(before);
|
|
const [kept, returned] = before.offers.a.cards;
|
|
const chosen = chooseLeaderSkill(
|
|
before,
|
|
'a',
|
|
'setup-1',
|
|
kept,
|
|
'a-leader-1',
|
|
['a-leader-1', 'a-leader-2'],
|
|
KEEP_ORDER,
|
|
);
|
|
|
|
assert.deepEqual(before, snapshot);
|
|
assert.deepEqual(chosen.assignments, [
|
|
{ owner: 'a', leader: 'a-leader-1', skill: kept },
|
|
]);
|
|
assert.equal(chosen.offers.a, undefined);
|
|
assert.ok(chosen.deck.includes(returned));
|
|
assert.equal(chosen.deck.includes(kept), false);
|
|
validateLeaderSkills(chosen, rosterFor(['a', 'b']));
|
|
|
|
expectImmutableRejection(
|
|
chosen,
|
|
() =>
|
|
chooseLeaderSkill(
|
|
chosen,
|
|
'a',
|
|
'setup-1',
|
|
kept,
|
|
'a-leader-2',
|
|
['a-leader-2'],
|
|
KEEP_ORDER,
|
|
),
|
|
/current Leader Skill cards|already has an assigned Leader Skill/,
|
|
);
|
|
});
|
|
|
|
void test('saved custody rejects an offer for an owner who already has an assigned skill', () => {
|
|
const offered = dealtState();
|
|
const assigned = chooseLeaderSkill(
|
|
offered,
|
|
'a',
|
|
'setup-1',
|
|
offered.offers.a.cards[0],
|
|
'a-leader-1',
|
|
['a-leader-1', 'a-leader-2'],
|
|
KEEP_ORDER,
|
|
);
|
|
const invalid = structuredClone(assigned);
|
|
invalid.offers.a = {
|
|
event: 'impossible-second-offer',
|
|
cards: invalid.deck.splice(0, 2),
|
|
leader: null,
|
|
};
|
|
assert.throws(
|
|
() => validateLeaderSkills(invalid, rosterFor(['a', 'b'])),
|
|
/assignment|offer|original skill/i,
|
|
);
|
|
});
|
|
|
|
void test('rejects the wrong owner, event, physical card, or leader without changing custody', () => {
|
|
const state = dealtState();
|
|
const card = state.offers.a.cards[0];
|
|
const unofferedCard = state.deck[0];
|
|
|
|
const attempts: [() => unknown, RegExp][] = [
|
|
[
|
|
() =>
|
|
chooseLeaderSkill(
|
|
state,
|
|
'intruder',
|
|
'setup-1',
|
|
card,
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
),
|
|
/current Leader Skill cards/,
|
|
],
|
|
[
|
|
() =>
|
|
chooseLeaderSkill(
|
|
state,
|
|
'a',
|
|
'stale-event',
|
|
card,
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
),
|
|
/current Leader Skill cards/,
|
|
],
|
|
[
|
|
() =>
|
|
chooseLeaderSkill(
|
|
state,
|
|
'a',
|
|
'setup-1',
|
|
unofferedCard,
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
),
|
|
/current Leader Skill cards/,
|
|
],
|
|
[
|
|
() =>
|
|
chooseLeaderSkill(
|
|
state,
|
|
'a',
|
|
'setup-1',
|
|
card,
|
|
'a-captured-or-dead-leader',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
),
|
|
/eligible living leader/,
|
|
],
|
|
];
|
|
|
|
for (const [action, message] of attempts)
|
|
expectImmutableRejection(state, action, message);
|
|
|
|
expectImmutableRejection(
|
|
state,
|
|
() =>
|
|
chooseLeaderSkill(
|
|
state,
|
|
'a',
|
|
'setup-1',
|
|
card,
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
() => Number.NaN,
|
|
),
|
|
/Invalid skill shuffle/,
|
|
);
|
|
});
|
|
|
|
void test('setup input rejection leaves the pristine physical inventory untouched', () => {
|
|
const state = createLeaderSkills(KEEP_ORDER);
|
|
const attempts: [readonly string[], string][] = [
|
|
[['a'], 'setup'],
|
|
[['a', 'b', 'c', 'd', 'e', 'f', 'g'], 'setup'],
|
|
[['a', 'a'], 'setup'],
|
|
[['a', 'b'], ''],
|
|
];
|
|
|
|
for (const [players, event] of attempts)
|
|
expectImmutableRejection(
|
|
state,
|
|
() => dealLeaderSkills(state, players, event),
|
|
/two through six distinct skill recipients/,
|
|
);
|
|
});
|
|
|
|
void test('death returns a skill exactly once while a living captured leader keeps it attached', () => {
|
|
const offered = dealtState();
|
|
const skill = offered.offers.a.cards[0];
|
|
const assigned = chooseLeaderSkill(
|
|
offered,
|
|
'a',
|
|
'setup-1',
|
|
skill,
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
);
|
|
const capturedRoster: SkillRoster = [
|
|
{
|
|
id: 'a',
|
|
leaders: [
|
|
{ id: 'a-leader-1', dead: false, capturedBy: 'b' },
|
|
{ id: 'a-leader-2', dead: false },
|
|
],
|
|
},
|
|
...rosterFor(['b']),
|
|
];
|
|
|
|
const captured = returnDeadLeaderSkills(assigned, capturedRoster, KEEP_ORDER);
|
|
assert.equal(captured.state, assigned);
|
|
assert.deepEqual(captured.returned, []);
|
|
assert.deepEqual(captured.state.assignments, assigned.assignments);
|
|
assert.equal(
|
|
offerRevivedLeaderSkill(assigned, 'a', 'a-leader-2', 'revival-blocked'),
|
|
assigned,
|
|
);
|
|
|
|
const deathRoster: SkillRoster = [
|
|
{
|
|
id: 'a',
|
|
leaders: [
|
|
{ id: 'a-leader-1', dead: true, capturedBy: 'b' },
|
|
{ id: 'a-leader-2', dead: false },
|
|
],
|
|
},
|
|
...rosterFor(['b']),
|
|
];
|
|
const assignedSnapshot = structuredClone(assigned);
|
|
const death = returnDeadLeaderSkills(assigned, deathRoster, KEEP_ORDER);
|
|
assert.deepEqual(assigned, assignedSnapshot);
|
|
assert.deepEqual(death.returned, [
|
|
{ owner: 'a', leader: 'a-leader-1', skill },
|
|
]);
|
|
assert.deepEqual(death.state.assignments, []);
|
|
assert.ok(death.state.deck.includes(skill));
|
|
|
|
let secondShuffleCalls = 0;
|
|
const repeated = returnDeadLeaderSkills(death.state, deathRoster, () => {
|
|
secondShuffleCalls += 1;
|
|
return 0;
|
|
});
|
|
assert.equal(repeated.state, death.state);
|
|
assert.deepEqual(repeated.returned, []);
|
|
assert.equal(secondShuffleCalls, 0);
|
|
validateLeaderSkills(death.state, capturedRoster);
|
|
});
|
|
|
|
void test('revival asks whether to draw before the private preview and requires a keep after drawing', () => {
|
|
const initial = createLeaderSkills(KEEP_ORDER);
|
|
const originalDeck = [...initial.deck];
|
|
const offered = offerRevivedLeaderSkill(
|
|
initial,
|
|
'a',
|
|
'a-leader-1',
|
|
'revival-1',
|
|
);
|
|
|
|
assert.deepEqual(initial.deck, originalDeck);
|
|
assert.deepEqual(offered.offers.a, {
|
|
event: 'revival-1',
|
|
cards: [],
|
|
leader: 'a-leader-1',
|
|
});
|
|
assert.deepEqual(offered.deck, originalDeck);
|
|
|
|
const declined = declineRevivedLeaderSkill(offered, 'a', 'revival-1');
|
|
assert.equal(declined.offers.a, undefined);
|
|
assert.deepEqual(declined.deck, originalDeck);
|
|
|
|
const redrawnOffer = offerRevivedLeaderSkill(
|
|
declined,
|
|
'a',
|
|
'a-leader-1',
|
|
'revival-2',
|
|
);
|
|
const preview = drawRevivedLeaderSkills(redrawnOffer, 'a', 'revival-2');
|
|
assert.deepEqual(preview.offers.a.cards, originalDeck.slice(0, 2));
|
|
assert.deepEqual(preview.deck, originalDeck.slice(2));
|
|
|
|
expectImmutableRejection(
|
|
preview,
|
|
() => declineRevivedLeaderSkill(preview, 'a', 'revival-2'),
|
|
/undrawn revival skill offer/,
|
|
);
|
|
expectImmutableRejection(
|
|
preview,
|
|
() =>
|
|
chooseLeaderSkill(
|
|
preview,
|
|
'a',
|
|
'revival-2',
|
|
preview.offers.a.cards[0],
|
|
'a-leader-2',
|
|
['a-leader-1', 'a-leader-2'],
|
|
KEEP_ORDER,
|
|
),
|
|
/eligible living leader/,
|
|
);
|
|
|
|
const kept = chooseLeaderSkill(
|
|
preview,
|
|
'a',
|
|
'revival-2',
|
|
preview.offers.a.cards[0],
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
);
|
|
assert.equal(kept.offers.a, undefined);
|
|
assert.deepEqual(kept.assignments, [
|
|
{ owner: 'a', leader: 'a-leader-1', skill: originalDeck[0] },
|
|
]);
|
|
validateLeaderSkills(kept, rosterFor(['a']));
|
|
});
|
|
|
|
void test('saved-state validation rejects duplicate inventory and wrong attachment without mutation', () => {
|
|
const roster = rosterFor(['a', 'b']);
|
|
const state = dealtState();
|
|
|
|
const duplicate = structuredClone(state);
|
|
duplicate.deck[duplicate.deck.length - 1] = duplicate.deck[0];
|
|
const duplicateSnapshot = structuredClone(duplicate);
|
|
assert.throws(
|
|
() => validateLeaderSkills(duplicate, roster),
|
|
/preserve all fourteen physical cards exactly once/,
|
|
);
|
|
assert.deepEqual(duplicate, duplicateSnapshot);
|
|
|
|
const card = state.offers.a.cards[0];
|
|
const assigned = chooseLeaderSkill(
|
|
state,
|
|
'a',
|
|
'setup-1',
|
|
card,
|
|
'a-leader-1',
|
|
['a-leader-1'],
|
|
KEEP_ORDER,
|
|
);
|
|
const wrongOwner = structuredClone(assigned);
|
|
wrongOwner.assignments[0].owner = 'b';
|
|
const wrongOwnerSnapshot = structuredClone(wrongOwner);
|
|
assert.throws(
|
|
() => validateLeaderSkills(wrongOwner, roster),
|
|
/original leader/,
|
|
);
|
|
assert.deepEqual(wrongOwner, wrongOwnerSnapshot);
|
|
});
|
|
|
|
void test('canonical physical inventory is unchanged when initial shuffling rejects a bad roll', () => {
|
|
const canonical: LeaderSkillId[] = LEADER_SKILL_CARDS.map((card) => card.id);
|
|
assert.throws(() => createLeaderSkills(() => 1), /Invalid skill shuffle/);
|
|
assert.deepEqual(
|
|
LEADER_SKILL_CARDS.map((card) => card.id),
|
|
canonical,
|
|
);
|
|
});
|