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.
This commit is contained in:
Quintine committed 2026-09-13 17:31:37 +10:00
1 parent 369371671f
commit 6e6fecc9d4
28 files changed
+4196 -38

No files matched your search

+483
View File
@@ -0,0 +1,483 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import test from 'node:test';
import { baseDeck, type Card } from '../game/cards';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { placeFixtureHand } from './fixture-hand';
const SEATS = [
['e', 'Emperor seat', 'emperor'],
['h', 'Harkonnen seat', 'harkonnen'],
['a', 'Atreides seat', 'atreides'],
['b', 'Bene Gesserit seat', 'beneGesserit'],
['f', 'Fremen seat', 'fremen'],
['g', 'Guild seat', 'guild'],
] as const;
function assertSkillCustody(g: Game) {
assert.ok(g.leaderSkills);
const physical = [
...g.leaderSkills.deck,
...g.leaderSkills.assignments.map((assignment) => assignment.skill),
...Object.values(g.leaderSkills.offers).flatMap((offer) => offer.cards),
];
assert.equal(physical.length, 14);
assert.equal(new Set(physical).size, 14);
assert.deepEqual(
[...physical].sort(),
LEADER_SKILL_CARDS.map((card) => card.id).sort(),
);
}
/** Put Warmaster in the Emperor's genuine two-card offer. */
function beginSetup(): Game {
let g = createGame(
'SKILLCAP',
newPlayer(SEATS[0][0], SEATS[0][1], SEATS[0][2]),
);
for (const [id, name, faction] of SEATS.slice(1))
joinGame(g, newPlayer(id, name, faction));
for (const [id] of SEATS) g = applyAction(g, id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === 'warmaster',
);
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
return array;
});
try {
g = initializeLeaderSkillsGameForAudit(g);
} finally {
mock.restoreAll();
}
assert.equal(g.setupStage, 'prediction');
g = applyAction(g, 'b', {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(g.setupStage, 'leaderSkills');
assert.ok(g.leaderSkills?.offers.e.cards.includes('warmaster'));
assertSkillCustody(g);
return g;
}
function finishSetup(
state: Game,
emperorSkillLeader: 'emperor-1' | 'emperor-2',
): Game {
let g = state;
for (const [id] of SEATS) {
const view = viewGame(g, id);
const offer = view.leaderSkills!.offer!;
g = applyAction(g, id, {
type: 'leaderSkill',
event: offer.event,
skill: id === 'e' ? 'warmaster' : offer.cards[0],
leader:
id === 'e'
? emperorSkillLeader
: view.leaderSkills!.eligibleLeaders[0].id,
});
}
assert.equal(g.setupStage, 'traitors');
while (g.setupStage === 'traitors') {
const owner = g.players.find((player) => player.traitorChoices.length);
assert.ok(owner);
g = applyAction(g, owner.id, {
type: 'traitor',
leader: owner.traitorChoices[0],
});
}
assert.equal(g.setupStage, 'forces');
g = applyAction(g, 'f', {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(g.status, 'playing');
assertSkillCustody(g);
return g;
}
function takeCard(kind: Card['kind'], excluded: readonly string[] = []): Card {
const card = baseDeck().find(
(candidate) => candidate.kind === kind && !excluded.includes(candidate.id),
);
assert.ok(card, `expected a physical ${kind} card`);
return card;
}
function passResponses(state: Game): Game {
let g = state;
while (g.response) {
const owner = g.players.find(
(player) => !g.response!.passed.includes(player.id),
);
assert.ok(owner);
g = applyAction(g, owner.id, { type: 'passResponse' });
}
return g;
}
function clearBattlePreparation(state: Game): Game {
let g = state;
while (
g.decision?.kind === 'leaderSkillVisibility' ||
g.decision?.kind === 'fullPlanOffer' ||
g.battle?.preparation
)
if (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
event: g.decision.event,
hide: false,
});
else if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, {
type: 'decision',
decline: true,
});
else
g = applyAction(g, g.battle!.preparation!.owner, {
type: 'declineBattlePower',
});
return g;
}
function resolveBattle(state: Game): Game {
let g = applyAction(state, 'h', { type: 'traitorCall', call: false });
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
while (
g.decision?.kind === 'battleLosses' ||
g.decision?.kind === 'battleCards'
)
g = applyAction(
g,
g.decision.player,
g.decision.kind === 'battleLosses'
? { type: 'decision', choice: 0 }
: { type: 'decision', discard: [] },
);
return g;
}
function captureOffer(
emperorSkillLeader: 'emperor-1' | 'emperor-2' = 'emperor-1',
): Game {
let g = finishSetup(beginSetup(), emperorSkillLeader);
const emperor = g.players.find((player) => player.id === 'e')!;
const harkonnen = g.players.find((player) => player.id === 'h')!;
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
player.traitors = [];
}
Object.assign(g, {
advanced: true,
phase: 6,
active: 'h',
order: ['h', 'e', 'a', 'b', 'f', 'g'],
ready: [],
storm: 18,
decision: null,
response: null,
phaseOpening: null,
});
harkonnen.forces = { 'red_chasm:7': 10 };
harkonnen.reserves = 10;
emperor.forces = { 'red_chasm:7': 10 };
emperor.reserves = 10;
for (const leader of emperor.leaders.slice(2)) {
leader.dead = true;
leader.deaths = 1;
}
if (emperorSkillLeader === 'emperor-2') {
emperor.leaders[2].dead = false;
emperor.leaders[2].deaths = 0;
emperor.leaders[2].usedAt = 'arrakeen';
}
const poison = takeCard('poison');
placeFixtureHand(g, 1, [poison]);
g = applyAction(g, 'h', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
g = clearBattlePreparation(g);
assert.equal(g.decision, null, JSON.stringify(g.decision));
g = applyAction(g, 'h', {
type: 'battlePlan',
dial: 0,
leader: 'harkonnen-1',
weapon: poison.id,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
g = resolveBattle(g);
assert.equal(g.decision?.kind, 'captureOffer');
return g;
}
function acceptCapture(state: Game): Game {
let g = applyAction(state, 'h', { type: 'decision', accept: true });
g = passResponses(g);
assert.equal(g.decision?.kind, 'capturedLeader');
return g;
}
void test('the genuine audit setup offers eligible leaders to all six base factions including Harkonnen', () => {
const g = beginSetup();
assert.deepEqual(
new Set(g.players.map((player) => player.faction)),
new Set(SEATS.map(([, , faction]) => faction)),
);
for (const [id, , faction] of SEATS) {
const view = viewGame(g, id);
assert.equal(view.leaderSkills?.offer?.cards.length, 2, faction);
assert.equal(view.leaderSkills?.eligibleLeaders.length, 5, faction);
assert.ok(
view.leaderSkills!.eligibleLeaders.every((leader) =>
leader.id.startsWith(`${faction}-`),
),
faction,
);
}
assert.doesNotThrow(() => finishSetup(g, 'emperor-1'));
});
void test('a captured skilled leader and attached card become public while replacement revival remains rejected atomically', () => {
const g = acceptCapture(captureOffer());
for (const [viewer] of SEATS) {
const view = viewGame(g, viewer);
assert.deepEqual(
view.leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'e',
),
{
owner: 'e',
leader: 'emperor-1',
skill: 'warmaster',
controller: 'h',
captured: true,
faceUp: false,
},
);
assert.equal(
view.players
.find((player) => player.id === 'e')!
.leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
'h',
);
assert.equal(
view.decision?.kind === 'capturedLeader' && view.decision.leader,
'emperor-1',
);
}
assert.ok(
g.log.some(
(entry) =>
entry.text.includes('Captain Aramsham') &&
entry.text.includes('Warmaster'),
),
);
const kept = applyAction(g, 'h', { type: 'decision', mode: 'keep' });
kept.phase = 4;
kept.decision = null;
kept.response = null;
const emperor = kept.players.find((player) => player.id === 'e')!;
emperor.spice = 20;
emperor.leaderRevived = false;
const before = structuredClone(kept);
assert.throws(
() =>
applyAction(kept, 'e', {
type: 'reviveLeader',
leader: 'emperor-0',
}),
/replacement-skill ruling/,
);
assert.deepEqual(kept, before);
assertSkillCustody(kept);
});
void test('the captor gets only the lower battle bonus and a surviving captive returns with the same skill', () => {
let g = applyAction(acceptCapture(captureOffer()), 'h', {
type: 'decision',
mode: 'keep',
});
const emperor = g.players.find((player) => player.id === 'e')!;
const harkonnen = g.players.find((player) => player.id === 'h')!;
emperor.leaders[2].dead = false;
emperor.leaders[2].deaths = 0;
delete emperor.leaders[2].usedAt;
Object.assign(g, {
phase: 6,
active: 'h',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
harkonnen.forces = { 'red_chasm:7': 10 };
harkonnen.reserves = 10;
emperor.forces = { 'red_chasm:7': 10 };
emperor.reserves = 10;
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
player.traitors = [];
}
const firstWorthless = takeCard('worthless');
const secondWorthless = takeCard('worthless', [firstWorthless.id]);
placeFixtureHand(g, 1, [firstWorthless]);
placeFixtureHand(g, 0, [secondWorthless]);
g = applyAction(g, 'h', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
g = clearBattlePreparation(g);
g = applyAction(g, 'h', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
weapon: firstWorthless.id,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-2',
weapon: secondWorthless.id,
});
g = resolveBattle(g);
assert.ok(
g.log.some((entry) =>
entry.text.includes('Harkonnen won in Red Chasm (8–3)'),
),
);
const skillLogs = g.log.filter(
(entry) => entry.automatic?.name === 'Leader Skill',
);
assert.ok(
skillLogs.some(
(entry) =>
entry.text.includes('Warmaster') &&
entry.text.includes('gained 3 battle strength'),
),
);
assert.ok(
skillLogs.every(
(entry) =>
!entry.text.includes('Warmaster') ||
!entry.text.includes('gained 1 battle strength'),
),
);
assert.equal(
g.players
.find((player) => player.id === 'e')!
.leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
undefined,
);
for (const [viewer] of SEATS)
assert.deepEqual(
viewGame(g, viewer).leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'e',
),
{
owner: 'e',
leader: 'emperor-1',
skill: 'warmaster',
controller: 'e',
captured: false,
faceUp: true,
},
);
assertSkillCustody(g);
});
void test('executing the known skilled captive returns exactly that card and keeps the inferred identity public', () => {
let g = acceptCapture(captureOffer());
g = applyAction(g, 'h', { type: 'decision', mode: 'execute' });
assert.equal(
g.leaderSkills!.assignments.some(
(assignment) => assignment.leader === 'emperor-1',
),
false,
);
assert.equal(
g.leaderSkills!.deck.filter((skill) => skill === 'warmaster').length,
1,
);
for (const [viewer] of SEATS) {
const leader = viewGame(g, viewer)
.players.find((player) => player.id === 'e')!
.leaders.find((candidate) => candidate.id === 'emperor-1')!;
assert.equal(leader.dead, true);
assert.equal(leader.deaths, 2);
}
assert.ok(
g.log.some(
(entry) =>
entry.automatic?.name === 'Leader Skill lost' &&
entry.text.includes('Warmaster'),
),
);
assertSkillCustody(g);
});
void test('capturing an unskilled leader preserves the existing private identity boundary', () => {
const offered = captureOffer('emperor-2');
const outsiderBefore = viewGame(offered, 'g').players.find(
(player) => player.id === 'e',
)!.leaders;
const g = acceptCapture(offered);
assert.equal(g.players[0].leaders[1].capturedBy, 'h');
assert.deepEqual(
viewGame(g, 'g').players.find((player) => player.id === 'e')!.leaders,
outsiderBefore,
);
const outsiderDecision = viewGame(g, 'g').decision;
const captorDecision = viewGame(g, 'h').decision;
assert.equal(
outsiderDecision?.kind === 'capturedLeader' && outsiderDecision.leader,
'',
);
assert.equal(
captorDecision?.kind === 'capturedLeader' && captorDecision.leader,
'emperor-1',
);
assert.deepEqual(
viewGame(g, 'g').leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'e',
),
{
owner: 'e',
leader: 'emperor-2',
skill: 'warmaster',
controller: 'e',
captured: false,
faceUp: true,
},
);
assert.ok(g.log.every((entry) => !entry.text.includes('Captain Aramsham')));
assertSkillCustody(g);
});