User ruling 13 October 2026: the original faction keeps its existing Skill assignment while its trained leader is captured. Reviving a different eligible own leader is legal and simply draws no replacement card while that assignment exists (selected reading of authorized supplied Advanced physical p21 "if you have no Leader Skill Card"). Not a new publisher ruling; captor lower-band use, card movement and death-return unchanged. Remove the replacement-question veto that suppressed the entire ordinary/Ghola revival: leaderSkillRevivalBlocked/requireLeaderSkillRevival, the gholaLeaders early return, the view revival.leaders: [] filter and the beginRevival/Ghola/ Emperor-special-Karama call sites are gone. offerRevivedLeaderSkill is unchanged and still suppresses a second assignment or draw. Rewrote the two committed rejection assertions (tests/advanced-native-skills.test.ts, tests/leader-skills-capture.test.ts) and added native regressions covering ordinary paid, held-Ghola and Emperor special Karama revival from a genuinely captured skilled state: exact cost, no draw or offer, retained captive/card/controller and a conserved all14 deck. Docs: RULE_DECISIONS, LEADER_SKILLS_CAPTURE, LEADER_SKILLS_RULES, LEADER_SKILLS_RUNTIME, NEXUS_CARD_RULES, CURRENT_STATUS, IMPLEMENTATION_STATUS and the game/reference.ts leader-skills topic.
540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
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';
|
||
import { botActions } from '../game/bots';
|
||
|
||
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 === 'mentatQuestion' ||
|
||
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 === 'mentatQuestion')
|
||
g = applyAction(g, g.decision.player, {
|
||
type: 'decision',
|
||
event: g.decision.event,
|
||
decline: true,
|
||
});
|
||
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 another own leader revives without a replacement offer', () => {
|
||
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;
|
||
// User ruling 13 October 2026: the original assignment survives the capture,
|
||
// so the ordinary revival proceeds with no replacement draw.
|
||
const skills = structuredClone(kept.leaderSkills);
|
||
const revived = passResponses(
|
||
applyAction(kept, 'e', { type: 'reviveLeader', leader: 'emperor-0' }),
|
||
);
|
||
assert.equal(
|
||
revived.players
|
||
.find((player) => player.id === 'e')!
|
||
.leaders.find((leader) => leader.id === 'emperor-0')!.dead,
|
||
false,
|
||
);
|
||
assert.equal(
|
||
revived.players
|
||
.find((player) => player.id === 'e')!
|
||
.leaders.find((leader) => leader.id === 'emperor-1')!.capturedBy,
|
||
'h',
|
||
);
|
||
assert.deepEqual(revived.leaderSkills, skills);
|
||
assert.equal(revived.leaderSkills!.offers.e, undefined);
|
||
assertSkillCustody(revived);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
void test('a captured trained leader still offers a legal Ghola revival while the physical skill and captive stay put', () => {
|
||
const kept = applyAction(acceptCapture(captureOffer()), 'h', { type: 'decision', mode: 'keep' });
|
||
kept.phase = 7;
|
||
kept.decision = null;
|
||
kept.response = null;
|
||
kept.phaseOpening = null;
|
||
kept.ready = kept.players.filter(player => player.id !== 'e').map(player => player.id);
|
||
const emperor = kept.players.find(player => player.id === 'e')!;
|
||
const ghola = baseDeck().find(card => card.effect === 'ghola')!;
|
||
placeFixtureHand(kept, kept.players.indexOf(emperor), [ghola]);
|
||
emperor.bot = 'Medium';
|
||
const skills = structuredClone(kept.leaderSkills);
|
||
const view = viewGame(kept, 'e');
|
||
// User ruling 13 October 2026: the capture no longer hides Ghola targets.
|
||
// Only the captured emperor-1 is excluded; the four dead discs are offered.
|
||
const dead = ['emperor-0', 'emperor-2', 'emperor-3', 'emperor-4'];
|
||
assert.deepEqual(view.ghola.leaders.map(leader => leader.id).sort(), dead);
|
||
assert.equal(view.ghola.available, true);
|
||
assert.equal(view.ghola.maxForces, 5, 'Captured training does not prohibit physical force Ghola');
|
||
const action = botActions(view).find(
|
||
(candidate) => candidate.type === 'card' && candidate.card === ghola.id,
|
||
);
|
||
assert.ok(action, 'Bot custody no longer strands the legal Ghola revival');
|
||
const done = applyAction(kept, 'e', action);
|
||
assert.equal(
|
||
done.players
|
||
.find((player) => player.id === 'e')!
|
||
.leaders.find((leader) => leader.id === action.leader)!.dead,
|
||
false,
|
||
);
|
||
assert.equal(
|
||
done.players.find((player) => player.id === 'e')!.hand.some((card) => card.id === ghola.id),
|
||
false,
|
||
);
|
||
assert.deepEqual(done.leaderSkills, skills);
|
||
assertSkillCustody(done);
|
||
});
|