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

+13 -1
View File
@@ -109,7 +109,17 @@ void test('Collection look remains private, deferred reveal survives reload, and
void test('Treachery Card Stash draws past a full hand, keeps the draw private and accepts every held discard through recovery', () => {
let g = discoveryFixture();
const token = enterDiscoveryCollection(g, 'treachery-card-stash');
// Exercise the previously flaky ID prefix collision with two distinct cards.
const take = (id: string) => {
const pile = [g.deck, g.discard, ...g.players.map((p) => p.hand)]
.find((cards) => cards.some((card) => card.id === id))!;
return pile.splice(pile.findIndex((card) => card.id === id), 1)[0];
};
const privateCard = take('treachery-3');
const visibleSibling = take('treachery-32');
g.players.find((p) => p.id === 'f')!.hand.push(visibleSibling);
while (g.players[0].hand.length < 4) g.players[0].hand.push(g.deck.shift()!);
g.deck.unshift(privateCard);
const original = g.players[0].hand.map((c) => c.id),
drawn = g.deck[0];
g = applyAction(g, 'a', {
@@ -120,7 +130,9 @@ void test('Treachery Card Stash draws past a full hand, keeps the draw private a
g = applyAction(g, 'a', { type: 'discovery', token: token.id, reveal: true });
assert.equal(g.decision?.kind, 'discoveryDiscard');
assert.equal(g.players[0].hand.length, 5);
assert.equal(JSON.stringify(viewGame(g, 'f')).includes(drawn.id), false);
const otherView = JSON.stringify(viewGame(g, 'f'));
assert.equal(otherView.includes(JSON.stringify(visibleSibling.id)), true);
assert.equal(otherView.includes(JSON.stringify(drawn.id)), false);
assert.deepEqual(
g.players[0].hand.map((c) => c.id),
[...original, drawn.id],
+126
View File
@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
LEADER_SKILL_CARDS,
leaderSkillCard,
type LeaderSkillId,
} from '../game/leader-skill-cards';
const EXPECTED_IDS: readonly LeaderSkillId[] = [
'bureaucrat',
'spice-banker',
'diplomat',
'mentat',
'suk-graduate',
'rihani-decipherer',
'sandmaster',
'smuggler',
'planetologist',
'warmaster',
'master-of-assassins',
'swordmaster-of-ginaz',
'killer-medic',
'prana-bindu-adept',
];
void test('the canonical inventory has exactly fourteen unique physical identities', () => {
assert.equal(LEADER_SKILL_CARDS.length, 14);
assert.deepEqual(
LEADER_SKILL_CARDS.map((card) => card.id),
EXPECTED_IDS,
);
assert.equal(new Set(LEADER_SKILL_CARDS.map((card) => card.id)).size, 14);
assert.equal(new Set(LEADER_SKILL_CARDS.map((card) => card.name)).size, 14);
for (const card of LEADER_SKILL_CARDS) {
assert.deepEqual(Object.keys(card), ['id', 'name', 'normal', 'battle']);
assert.ok(card.normal.length > 0);
assert.ok(card.battle.length > 0);
assert.ok(card.normal.every((line) => line.length > 20));
assert.ok(card.battle.every((line) => line.length > 20));
assert.match(card.id, /^[a-z]+(?:-[a-z]+)*$/);
}
});
void test('lookup is total for canonical IDs and rejects unknown runtime input', () => {
for (const card of LEADER_SKILL_CARDS)
assert.equal(leaderSkillCard(card.id), card);
assert.throws(
() => leaderSkillCard('not-a-physical-skill' as LeaderSkillId),
/Unknown Leader Skill Card/,
);
});
void test('the four weapon and defense disciplines preserve their distinct roles and values', () => {
const roles = [
['master-of-assassins', 'Poison Weapon'],
['swordmaster-of-ginaz', 'Projectile Weapon'],
['killer-medic', 'Poison Defense'],
['prana-bindu-adept', 'Projectile Defense'],
] as const;
for (const [id, role] of roles) {
const card = leaderSkillCard(id);
assert.match(card.normal.join(' '), new RegExp(`Add 1.*other.*${role}`));
assert.match(card.battle.join(' '), new RegExp(`Add 3.*${role}`));
}
});
void test('named economic, custody and information effects retain their source numbers', () => {
assert.match(
leaderSkillCard('bureaucrat').normal.join(' '),
/per phase.*at least 5.*2.*Spice Bank/,
);
assert.match(
leaderSkillCard('spice-banker').normal.join(' '),
/per phase.*at least 4.*1 spice.*Mentat Pause/,
);
assert.match(
leaderSkillCard('spice-banker').battle.join(' '),
/1–3.*separate.*full strength/,
);
assert.match(
leaderSkillCard('rihani-decipherer').battle.join(' '),
/draw 2 Traitor Cards.*unused Traitor Card.*unkept drawn card/,
);
assert.match(
leaderSkillCard('mentat').normal.join(' '),
/specific weapon.*different Treachery Card.*does not require/,
);
});
void test('movement and battle-loss cards remain materially different', () => {
assert.match(
leaderSkillCard('planetologist').normal.join(' '),
/capped at 3.*two different territories.*one destination/,
);
assert.match(
leaderSkillCard('planetologist').battle.join(' '),
/green Special.*other than a Cheap Hero.*add 2.*Discard/,
);
assert.match(
leaderSkillCard('smuggler').normal.join(' '),
/off-planet.*empty territory.*additional force.*free/,
);
assert.match(
leaderSkillCard('sandmaster').normal.join(' '),
/into or through.*1 spice.*once per territory/,
);
assert.match(
leaderSkillCard('diplomat').battle.join(' '),
/lose.*undialed forces.*empty adjacent.*not a stronghold/,
);
assert.match(
leaderSkillCard('suk-graduate').normal.join(' '),
/return 1.*reserves.*instead of the Tleilaxu Tanks/,
);
assert.match(
leaderSkillCard('suk-graduate').battle.join(' '),
/up to 3.*leave 1.*return the rest/,
);
});
void test('Warmaster uses one Worthless card for either nonstacking band', () => {
const warmaster = leaderSkillCard('warmaster');
assert.match(warmaster.normal.join(' '), /Add 1.*other.*at least one/);
assert.match(warmaster.battle.join(' '), /Add 3.*at least one/);
assert.doesNotMatch(JSON.stringify(warmaster), /at least two|Add 4/);
});
+295
View File
@@ -0,0 +1,295 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import {
leaderSkillBattleBonus,
type BattleLeaderSkill,
type DirectLeaderSkillId,
} from '../game/leader-skill-combat';
import {
quoteBattleResolution,
type BattleResolutionInput,
type ResolutionCombatant,
} from '../game/battle-resolution-quote';
const catalog = () => [...baseDeck(), ...ixBattleCards(), ...richeseCards()];
function one(predicate: (card: Card) => boolean) {
const card = catalog().find(predicate);
assert.ok(card);
return card;
}
const selected = { id: 'selected-disc', kind: 'disc' as const };
const assignment = (
skill: DirectLeaderSkillId,
leader = selected.id,
faceUp = false,
captured = false,
): BattleLeaderSkill => ({ skill, leader, faceUp, captured });
void test('all five direct families use the physical selected card role for normal +1 or skilled +3 without +4', () => {
const cases: [DirectLeaderSkillId, Card | undefined, Card | undefined][] = [
['warmaster', one((card) => card.kind === 'worthless'), undefined],
[
'master-of-assassins',
one((card) => card.kind === 'chemistry'),
undefined,
],
[
'swordmaster-of-ginaz',
one((card) => card.kind === 'weirdingWay'),
undefined,
],
['killer-medic', undefined, one((card) => card.kind === 'chemistry')],
[
'prana-bindu-adept',
undefined,
one((card) => card.kind === 'weirdingWay'),
],
];
for (const [skill, weapon, defense] of cases) {
assert.deepEqual(
leaderSkillBattleBonus({
assignments: [assignment(skill)],
selectedLeader: selected,
weapon,
defense,
skilledLeaderSurvives: true,
}),
{ bonus: 3, applied: [{ skill, amount: 3, mode: 'skilled' }] },
);
assert.deepEqual(
leaderSkillBattleBonus({
assignments: [assignment(skill, 'trainer', true)],
selectedLeader: selected,
weapon,
defense,
skilledLeaderSurvives: true,
}),
{ bonus: 1, applied: [{ skill, amount: 1, mode: 'normal' }] },
);
}
});
void test('dead, bluffed, face-up selected and captured assignments preserve their distinct effect bands', () => {
const weapon = one((card) => card.kind === 'projectile');
const quote = (skill: BattleLeaderSkill, survives = true) =>
leaderSkillBattleBonus({
assignments: [skill],
selectedLeader: selected,
weapon,
defense: undefined,
skilledLeaderSurvives: survives,
});
assert.equal(quote(assignment('swordmaster-of-ginaz'), false).bonus, 0);
assert.equal(
quote(assignment('swordmaster-of-ginaz', 'trainer', false)).bonus,
0,
'A concealed bluff supplies neither effect band.',
);
assert.equal(
quote(assignment('swordmaster-of-ginaz', selected.id, true)).bonus,
0,
'A still-public skilled disc cannot receive its lower battle effect.',
);
assert.equal(
quote(assignment('swordmaster-of-ginaz', 'trainer', true, true)).bonus,
0,
'A captured assignment cannot train another leader.',
);
assert.equal(
quote(assignment('swordmaster-of-ginaz', selected.id, false, true)).bonus,
3,
'A surviving captured skilled leader retains only its lower effect.',
);
assert.equal(
leaderSkillBattleBonus({
assignments: [assignment('swordmaster-of-ginaz', 'trainer', true)],
selectedLeader: { id: 'cheap-hero', kind: 'hero' },
weapon,
defense: undefined,
skilledLeaderSurvives: true,
}).bonus,
1,
'Cheap Hero substitutes for another leader for the public normal band.',
);
assert.equal(
leaderSkillBattleBonus({
assignments: [assignment('swordmaster-of-ginaz', 'trainer', true)],
selectedLeader: undefined,
weapon,
defense: undefined,
skilledLeaderSurvives: true,
}).bonus,
0,
);
});
function battle(): BattleResolutionInput {
const side = (
id: string,
faction: 'emperor' | 'guild',
strength: number,
): ResolutionCombatant => {
const leader = { ...leaders(faction)[0], strength };
return {
id,
faction,
spice: 10,
hand: [],
leader,
plan: {
dial: 2,
support: 0,
leader: leader.id,
weapon: null,
defense: null,
},
forces: {
normal: 5,
elite: 0,
eliteStrength: 2,
freeSupport: false,
},
};
};
return {
advanced: false,
turn: 2,
territory: 'arrakeen',
attacker: side('a', 'emperor', 3),
defender: side('d', 'guild', 2),
voters: [
{ id: 'a', beneficiary: 'a', called: false, traitors: [] },
{ id: 'd', beneficiary: 'd', called: false, traitors: [] },
],
participants: [
{ id: 'a', faction: 'emperor' },
{ id: 'd', faction: 'guild' },
],
physicalCards: catalog(),
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
function play(
input: BattleResolutionInput,
side: 'attacker' | 'defender',
slot: 'leader' | 'weapon' | 'defense' | 'lateDefense',
predicate: (card: Card) => boolean,
) {
const card = one(predicate);
input[side].hand = [...input[side].hand, card];
if (slot === 'lateDefense') input[side].lateDefense = card.id;
else input[side].plan[slot] = card.id;
if (slot === 'leader') delete input[side].leader;
return card;
}
void test('battle quote adds only surviving skill receipts to scores while preserving printed strength and bounty', () => {
const input = battle();
const worthless = play(
input,
'attacker',
'weapon',
(card) => card.kind === 'worthless',
);
input.attacker.leaderSkills = [
assignment('warmaster', input.attacker.leader!.id),
];
const before = structuredClone(input);
const quote = quoteBattleResolution(input);
assert.deepEqual(quote.leaderStrengths, { attacker: 3, defender: 2 });
assert.deepEqual(quote.leaderSkillBonuses.attacker, {
bonus: 3,
applied: [{ skill: 'warmaster', amount: 3, mode: 'skilled' }],
});
assert.deepEqual(quote.scores, { attacker: 8, defender: 4 });
assert.deepEqual(input, before);
const killed = structuredClone(input);
play(killed, 'defender', 'weapon', (card) => card.kind === 'poison');
const resolved = quoteBattleResolution(killed);
assert.equal(resolved.winner, 'd');
assert.equal(resolved.leaderSkillBonuses.attacker.bonus, 0);
assert.equal(resolved.scores!.attacker, 2);
assert.equal(resolved.bounty!.amount, 3);
assert.equal(killed.attacker.plan.weapon, worthless.id);
});
void test('a supplemental physical defense activates its matching surviving skill role', () => {
const input = battle();
const snooper = play(
input,
'attacker',
'lateDefense',
(card) => card.kind === 'snooper',
);
input.attacker.leaderSkills = [
assignment('killer-medic', input.attacker.leader!.id),
];
const quote = quoteBattleResolution(input);
assert.equal(input.attacker.plan.defense, null);
assert.equal(input.attacker.lateDefense, snooper.id);
assert.deepEqual(quote.leaderSkillBonuses.attacker, {
bonus: 3,
applied: [{ skill: 'killer-medic', amount: 3, mode: 'skilled' }],
});
assert.deepEqual(quote.scores, { attacker: 8, defender: 4 });
});
void test('normal training follows a Cheap Hero substitution but traitor precedence suppresses skill scoring', () => {
const input = battle();
const hero = play(
input,
'attacker',
'leader',
(card) => card.kind === 'hero',
);
play(input, 'attacker', 'weapon', (card) => card.kind === 'projectile');
input.attacker.leaderSkills = [
assignment('swordmaster-of-ginaz', 'public-trainer', true),
];
let quote = quoteBattleResolution(input);
assert.equal(quote.leaderStrengths.attacker, 0);
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 1);
assert.equal(quote.scores!.attacker, 3);
input.voters = [
input.voters[0],
{
id: 'd',
beneficiary: 'd',
called: true,
traitors: [CHEAP_HERO_TRAITOR],
},
];
quote = quoteBattleResolution(input);
assert.equal(quote.result, 'traitor');
assert.equal(quote.winner, 'd');
assert.equal(quote.scores, null);
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 0);
assert.equal(quote.bounty!.amount, 0);
assert.equal(hero.kind, 'hero');
});
void test('Stone Burner comparison remains independent of a surviving skilled bonus', () => {
const input = battle();
input.attacker.forces.freeSupport = true;
input.defender.forces.freeSupport = true;
input.attacker.plan.dial = 4;
input.defender.plan.dial = 1;
play(input, 'attacker', 'weapon', (card) => card.effect === 'stoneBurner');
play(input, 'attacker', 'defense', (card) => card.kind === 'snooper');
input.attacker.stoneMode = 'ignore';
input.attacker.leaderSkills = [
assignment('killer-medic', input.attacker.leader!.id),
];
const quote = quoteBattleResolution(input);
assert.equal(quote.result, 'normal');
assert.equal(quote.leaderSkillBonuses.attacker.bonus, 3);
assert.deepEqual(quote.scores, { attacker: 10, defender: 3 });
assert.equal(quote.stone?.winner, 'defender');
assert.equal(quote.winner, 'd');
});
+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);
});
+213
View File
@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { LeaderSkillsPanel } from '../components/leader-skills';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { LeaderSkillsView } from '../game/leader-skills';
const leaders = [
{ id: 'duke-leto', name: 'Duke Leto Atreides' },
{ id: 'lady-jessica', name: 'Lady Jessica' },
{ id: 'thufir-hawat', name: 'Thufir Hawat' },
] as const;
const players = [
{ id: 'a', name: 'Atreides' },
{ id: 'e', name: 'Emperor' },
] as const;
function view(overrides: Partial<LeaderSkillsView> = {}): LeaderSkillsView {
return {
assignments: [],
offer: null,
eligibleLeaders: [],
battleChoice: null,
...overrides,
};
}
function markup(skills: LeaderSkillsView | null, busy = false): string {
return renderToStaticMarkup(
createElement(LeaderSkillsPanel, {
skills,
leaders,
players,
busy,
act() {},
}),
);
}
function disabledControls(html: string): number {
return (html.match(/<(?:button|input|select)\b[^>]*\sdisabled=""/g) ?? [])
.length;
}
void test('renders nothing when the projected module is absent and never invents a private offer', () => {
assert.equal(markup(null), '');
const publicOnly = markup(
view({
assignments: [
{
owner: 'a',
controller: 'a',
captured: false,
leader: 'duke-leto',
skill: 'warmaster',
faceUp: true,
},
],
}),
);
assert.match(publicOnly, /Public Leader Skill assignments/);
assert.match(publicOnly, /Atreides · Duke Leto Atreides · Face up/);
assert.doesNotMatch(
publicOnly,
/Your private offer|Choose one private skill/,
);
assert.doesNotMatch(publicOnly, /Bureaucrat|Spice Banker|Diplomat/);
});
void test('every public assignment card exposes its complete canonical normal and battle text', () => {
for (const card of LEADER_SKILL_CARDS) {
const html = markup(
view({
assignments: [
{
owner: 'a',
controller: 'a',
captured: false,
leader: 'duke-leto',
skill: card.id,
faceUp: false,
},
],
}),
);
assert.match(
html,
new RegExp(`aria-label="${card.name} Leader Skill card"`),
);
assert.match(html, new RegExp(`Inspect ${card.name}`));
assert.match(html, /Atreides · Duke Leto Atreides · Behind shield/);
assert.ok(html.includes(`aria-label="${card.name} normal effect"`));
assert.ok(html.includes(`aria-label="${card.name} battle effect"`));
for (const line of [...card.normal, ...card.battle])
assert.ok(html.includes(line), `${card.name} omitted: ${line}`);
}
});
void test('a private setup offer renders only its two physical cards and eligible leader selection', () => {
const skills = view({
offer: {
event: 'setup-event',
cards: ['bureaucrat', 'diplomat'],
leader: null,
},
eligibleLeaders: [leaders[0], leaders[1]],
});
const html = markup(skills);
assert.match(html, /aria-label="Choose a Leader Skill"/);
assert.match(html, /Keep Bureaucrat/);
assert.match(html, /Keep Diplomat/);
assert.match(html, /Bureaucrat Leader Skill card/);
assert.match(html, /Diplomat Leader Skill card/);
assert.doesNotMatch(
html,
/Mentat Leader Skill card|Spice Banker Leader Skill card/,
);
assert.match(html, /aria-label="Eligible Leader Skill leader"/);
assert.match(html, /Duke Leto Atreides/);
assert.match(html, /Lady Jessica/);
assert.match(html, /Assign selected skill/);
assert.equal((html.match(/type="radio"/g) ?? []).length, 2);
assert.equal((html.match(/checked=""/g) ?? []).length, 1);
const disabled = markup(skills, true);
assert.equal(disabledControls(disabled), 4);
});
void test('revival asks before preview and does not offer a decline after cards are drawn', () => {
const undrawn = markup(
view({
offer: { event: 'revival-event', cards: [], leader: 'thufir-hawat' },
eligibleLeaders: [leaders[2]],
}),
);
assert.match(undrawn, /Skill for Thufir Hawat/);
assert.match(undrawn, /before seeing either private card/);
assert.match(undrawn, /Draw two skills/);
assert.match(undrawn, /Continue without a skill/);
assert.doesNotMatch(undrawn, /Bureaucrat|Diplomat|Your private offer/);
assert.equal(
disabledControls(
markup(
view({
offer: { event: 'revival-event', cards: [], leader: 'thufir-hawat' },
eligibleLeaders: [leaders[2]],
}),
true,
),
),
2,
);
const drawn = markup(
view({
offer: {
event: 'revival-event',
cards: ['bureaucrat', 'diplomat'],
leader: 'thufir-hawat',
},
eligibleLeaders: [leaders[2]],
}),
);
assert.match(drawn, /Assigned leader: <strong>Thufir Hawat<\/strong>/);
assert.match(drawn, /choosing one is mandatory/);
assert.match(drawn, /Assign selected skill/);
assert.doesNotMatch(drawn, /Continue without a skill|Draw two skills/);
assert.doesNotMatch(drawn, /aria-label="Eligible Leader Skill leader"/);
});
void test('battle visibility choice names the skilled leader and disables both actions while busy', () => {
const skills = view({
assignments: [
{
owner: 'a',
controller: 'a',
captured: false,
leader: 'duke-leto',
skill: 'master-of-assassins',
faceUp: true,
},
],
battleChoice: {
event: 'battle-event',
leader: 'duke-leto',
skill: 'master-of-assassins',
},
});
const html = markup(skills);
assert.match(html, /aria-label="Leader Skill battle visibility"/);
assert.match(html, /Duke Leto Atreides in this battle/);
assert.match(html, /Keep leader face up/);
assert.match(html, /Move leader behind shield/);
assert.equal((html.match(/disabled=""/g) ?? []).length, 0);
assert.equal(disabledControls(markup(skills, true)), 2);
});
void test('the panel states the exact five connected role modifiers and unfinished boundary', () => {
const html = markup(view());
for (const name of [
'Warmaster',
'Master of Assassins',
'Swordmaster of Ginaz',
'Killer Medic',
'Prana-Bindu Adept',
])
assert.match(html, new RegExp(name));
assert.match(html, /development-only module/);
assert.match(html, /Other Leader Skill effects are unfinished/);
});
+558
View File
@@ -0,0 +1,558 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import test from 'node:test';
import type { Card } from '../game/cards';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
LEADER_SKILL_CARDS,
type LeaderSkillId,
} from '../game/leader-skill-cards';
import { newRevivalRules } from '../game/revival';
const DIRECT_SKILLS = [
'warmaster',
'master-of-assassins',
'swordmaster-of-ginaz',
'killer-medic',
'prana-bindu-adept',
] as const;
function rejectUnchanged(g: Game, id: string, action: Action, message: RegExp) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action), message);
assert.deepEqual(g, before);
}
/** Put one requested physical skill first without bypassing the real setup deal. */
function initialized(target: LeaderSkillId = 'warmaster'): Game {
let g = createGame('SKILLENG', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
const targetIndex = LEADER_SKILL_CARDS.findIndex(
(card) => card.id === target,
);
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.leaderSkills!.offers.a.cards[0], target);
return g;
}
function initializedVoiceGame(): Game {
let g = createGame('SKILLVOICE', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
for (const player of g.players)
g = applyAction(g, player.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();
}
g = applyAction(g, 'b', {
type: 'predict',
faction: 'atreides',
turn: 3,
});
for (const id of ['a', 'b', 'e']) {
const skills = viewGame(g, id).leaderSkills!;
g = applyAction(g, id, {
type: 'leaderSkill',
event: skills.offer!.event,
skill: skills.offer!.cards[0],
leader: skills.eligibleLeaders[0].id,
});
}
return finishSetup(g);
}
function assignSetup(state: Game, skill: LeaderSkillId): Game {
let g = state;
const atreides = g.leaderSkills!.offers.a;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event: atreides.event,
skill,
leader: 'atreides-0',
});
const emperor = g.leaderSkills!.offers.e;
g = applyAction(g, 'e', {
type: 'leaderSkill',
event: emperor.event,
skill: emperor.cards[0],
leader: 'emperor-0',
});
return g;
}
function finishSetup(state: Game): Game {
let g = state;
for (let step = 0; step < 8 && g.status === 'setup'; step += 1) {
const owner = g.players.find((player) => player.traitorChoices.length > 0);
assert.ok(owner, `setup stalled at ${g.setupStage}`);
g = applyAction(g, owner.id, {
type: 'traitor',
leader: owner.traitorChoices[0],
});
}
assert.equal(g.status, 'playing');
return g;
}
function takeCard(g: Game, owner: string, predicate: (card: Card) => boolean) {
const index = g.deck.findIndex(predicate);
assert.ok(index >= 0, 'the requested physical battle card must exist');
const [card] = g.deck.splice(index, 1);
g.players.find((player) => player.id === owner)!.hand.push(card);
return card;
}
function battleFor(
skill: (typeof DIRECT_SKILLS)[number],
hide: boolean,
onlySkilledLeader = false,
) {
const g = finishSetup(assignSetup(initialized(skill), skill));
const attacker = g.players.find((player) => player.id === 'a')!;
const defender = g.players.find((player) => player.id === 'e')!;
attacker.forces = { 'arrakeen:10': 5 };
attacker.reserves = 15;
defender.forces = { 'arrakeen:10': 5 };
defender.reserves = 15;
Object.assign(g, {
phase: 6,
storm: 18,
order: ['a', 'e'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
}
if (onlySkilledLeader)
for (const leader of attacker.leaders.filter(
(leader) => leader.id !== 'atreides-0',
)) {
leader.dead = true;
leader.deaths = 1;
}
const role =
skill === 'warmaster'
? takeCard(g, 'a', (card) => card.kind === 'worthless')
: skill === 'master-of-assassins'
? takeCard(g, 'a', (card) => card.kind === 'poison')
: skill === 'swordmaster-of-ginaz'
? takeCard(g, 'a', (card) => card.kind === 'projectile')
: skill === 'killer-medic'
? takeCard(g, 'a', (card) => card.kind === 'snooper')
: takeCard(g, 'a', (card) => card.kind === 'shield');
let next = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'e',
});
while (next.decision?.kind === 'leaderSkillVisibility') {
const decision = next.decision;
next = applyAction(next, decision.player, {
type: 'leaderSkillVisibility',
event: decision.event,
hide: decision.player === 'a' ? hide : false,
});
}
while (next.battle?.preparation)
next = applyAction(next, next.battle.preparation.owner, {
type: 'declineBattlePower',
});
return { g: next, role };
}
function resolvePlans(state: Game, attackerLeader: string, role: Card): Game {
const slot =
role.kind === 'snooper' || role.kind === 'shield' ? 'defense' : 'weapon';
let g = applyAction(state, 'a', {
type: 'battlePlan',
dial: 0,
leader: attackerLeader,
[slot]: role.id,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-1',
});
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
return applyAction(g, 'e', { type: 'traitorCall', call: false });
}
function killedSkillBattle(): Game {
const { g: battle, role } = battleFor('master-of-assassins', true);
const poison = takeCard(
battle,
'e',
(card) => card.kind === 'poison' && card.id !== role.id,
);
let g = applyAction(battle, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
weapon: role.id,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 1,
leader: 'emperor-1',
weapon: poison.id,
});
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
return applyAction(g, 'e', { type: 'traitorCall', call: false });
}
void test('real setup deals private choices before traitors, then publishes only completed assignments across JSON views', () => {
let g = initialized();
assert.equal(g.setupStage, 'leaderSkills');
assert.ok(g.players.every((player) => player.hand.length === 1));
assert.ok(g.players.every((player) => player.traitorChoices.length === 0));
const aView = viewGame(g, 'a');
const eView = viewGame(g, 'e');
assert.equal(aView.leaderSkills!.offer!.cards.length, 2);
assert.equal(eView.leaderSkills!.offer!.cards.length, 2);
assert.notDeepEqual(
aView.leaderSkills!.offer!.cards,
eView.leaderSkills!.offer!.cards,
);
assert.equal('deck' in aView, false);
assert.equal('traitorReserve' in aView, false);
const event = g.leaderSkills!.offers.a.event;
rejectUnchanged(
g,
'e',
{
type: 'leaderSkill',
event,
skill: 'warmaster',
leader: 'atreides-0',
},
/current setup step|current Leader Skill cards/,
);
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
skill: 'warmaster',
leader: 'atreides-0',
});
for (const viewer of ['a', 'e']) {
const view = viewGame(JSON.parse(JSON.stringify(g)), viewer);
assert.deepEqual(view.leaderSkills!.assignments, [
{
owner: 'a',
leader: 'atreides-0',
skill: 'warmaster',
controller: 'a',
captured: false,
faceUp: true,
},
]);
assert.equal(
view.leaderSkills!.offer?.event ?? null,
g.leaderSkills!.offers[viewer]?.event ?? null,
);
}
g = assignSetup(initialized(), 'warmaster');
assert.equal(g.setupStage, 'traitors');
});
void test('each connected physical card role grants +3 only after its skilled leader moves behind the shield', () => {
for (const skill of DIRECT_SKILLS) {
const { g, role } = battleFor(skill, true);
assert.equal(
viewGame(g, 'a').leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'a',
)!.faceUp,
false,
);
const resolved = resolvePlans(g, 'atreides-0', role);
const defenderScore =
skill === 'master-of-assassins' || skill === 'swordmaster-of-ginaz'
? 0
: 5;
assert.ok(
resolved.log.some((entry) =>
entry.text.includes(`Atreides won in Arrakeen (8–${defenderScore})`),
),
`${skill} did not add three through the real engine battle`,
);
assert.ok(
resolved.log.some(
(entry) =>
entry.automatic?.name === 'Leader Skill' &&
entry.text.includes('gained 3 battle strength'),
),
);
}
});
void test('a face-up skilled leader is inadmissible while another leader and Cheap Hero receive the normal +1', () => {
const { g, role } = battleFor('warmaster', false);
rejectUnchanged(
g,
'a',
{
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
weapon: role.id,
},
/behind the shield/,
);
const resolved = resolvePlans(g, 'atreides-1', role);
assert.ok(
resolved.log.some((entry) =>
entry.text.includes('Atreides won in Arrakeen (6–5)'),
),
);
const heroBattle = battleFor('warmaster', false);
const hero = takeCard(heroBattle.g, 'a', (card) => card.kind === 'hero');
const heroResolved = resolvePlans(heroBattle.g, hero.id, heroBattle.role);
assert.ok(
heroResolved.log.some((entry) =>
entry.text.includes('Emperor won in Arrakeen (1–5)'),
),
);
const mandatory = battleFor('warmaster', true, true);
assert.equal(
viewGame(mandatory.g, 'a').leaderSkills!.assignments.find(
(assignment) => assignment.owner === 'a',
)!.faceUp,
false,
);
assert.doesNotThrow(() =>
resolvePlans(mandatory.g, 'atreides-0', mandatory.role),
);
});
void test('a Voice that forbids the last alternate Cheap Hero automatically conceals the only skilled leader without replaying powers', () => {
let g = initializedVoiceGame();
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
player.forces = {};
player.reserves = 20;
}
const heroIndex = g.deck.findIndex((card) => card.kind === 'hero');
assert.ok(heroIndex >= 0);
const [hero] = g.deck.splice(heroIndex, 1);
g.players.find((player) => player.id === 'a')!.hand.push(hero);
const atreides = g.players.find((player) => player.id === 'a')!;
atreides.forces = { 'arrakeen:10': 5 };
atreides.reserves = 15;
g.players.find((player) => player.id === 'b')!.forces = {
'arrakeen:10': 5,
};
g.players.find((player) => player.id === 'b')!.reserves = 15;
for (const leader of atreides.leaders.filter(
(leader) => leader.id !== 'atreides-0',
)) {
leader.dead = true;
leader.deaths = 1;
}
Object.assign(g, {
phase: 6,
storm: 18,
order: ['a', 'b', 'e'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
g = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'b',
});
while (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
event: g.decision.event,
hide: false,
});
assert.equal(g.battle!.preparation?.kind, 'voice');
g = applyAction(g, 'b', { type: 'voice', kind: 'hero', must: false });
assert.equal(g.battle!.leaderSkillHidden?.a, true);
assert.equal(
g.log.filter(
(entry) =>
entry.automatic?.name === 'Leader Skill' &&
entry.text.includes('behind the shield'),
).length,
1,
);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
});
assert.equal(g.battle!.plans.a.leader, 'atreides-0');
assert.equal(g.battle!.voice?.kind, 'hero');
});
void test('a killed skilled leader loses its card before scoring and a real revival privately offers a new draw', () => {
let g = killedSkillBattle();
assert.ok(
g.log.some((entry) => entry.text.includes('Emperor won in Arrakeen (0–1)')),
);
assert.equal(
g.leaderSkills!.assignments.some((assignment) => assignment.owner === 'a'),
false,
);
assert.ok(g.leaderSkills!.deck.includes('master-of-assassins'));
const atreides = g.players.find((player) => player.id === 'a')!;
for (const leader of atreides.leaders) {
leader.dead = true;
leader.deaths = Math.max(1, leader.deaths);
}
atreides.spice = 20;
atreides.leaderRevived = false;
g.phase = 4;
g.revivalRules = newRevivalRules();
g.decision = null;
g.response = null;
g = applyAction(g, 'a', {
type: 'reviveLeader',
leader: 'atreides-0',
});
assert.equal(g.decision?.kind, 'leaderSkillRevival');
assert.deepEqual(viewGame(g, 'a').leaderSkills!.offer!.cards, []);
assert.equal(viewGame(g, 'e').leaderSkills!.offer, null);
const event = g.decision!.event;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
mode: 'draw',
});
const privateOffer = viewGame(JSON.parse(JSON.stringify(g)), 'a')
.leaderSkills!.offer!;
assert.equal(privateOffer.cards.length, 2);
assert.equal(viewGame(g, 'e').leaderSkills!.offer, null);
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
skill: privateOffer.cards[0],
leader: 'atreides-0',
});
assert.equal(g.leaderSkills!.offers.a, undefined);
assert.equal(
g.leaderSkills!.assignments.find((assignment) => assignment.owner === 'a')
?.leader,
'atreides-0',
);
assert.doesNotThrow(() => viewGame(JSON.parse(JSON.stringify(g)), 'e'));
});
void test('Ghola revival during an unfinished battle preserves its power stage through the private draw and new visibility choice', () => {
let g = killedSkillBattle();
const atreides = g.players.find((player) => player.id === 'a')!;
const emperor = g.players.find((player) => player.id === 'e')!;
atreides.forces['carthag:11'] = 1;
atreides.reserves -= 1;
emperor.forces['carthag:11'] = 1;
emperor.reserves -= 1;
const ghola = takeCard(g, 'a', (card) => card.effect === 'ghola');
Object.assign(g, {
phase: 6,
storm: 18,
order: ['a', 'e'],
active: 'a',
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
g = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'carthag',
target: 'e',
});
while (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
event: g.decision.event,
hide: false,
});
assert.ok(g.battle!.preparation);
const preparation = structuredClone(g.battle!.preparation);
g = applyAction(g, 'a', {
type: 'card',
card: ghola.id,
leader: 'atreides-0',
});
assert.equal(g.decision?.kind, 'leaderSkillRevival');
assert.deepEqual(g.battle!.preparation, preparation);
const event = g.decision!.event;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
mode: 'draw',
});
const offer = viewGame(g, 'a').leaderSkills!.offer!;
g = applyAction(g, 'a', {
type: 'leaderSkill',
event,
skill: offer.cards[0],
leader: 'atreides-0',
});
assert.equal(g.decision?.kind, 'leaderSkillVisibility');
assert.equal(g.decision!.resumePowers, false);
assert.deepEqual(g.battle!.preparation, preparation);
g = applyAction(g, 'a', {
type: 'leaderSkillVisibility',
event: g.decision!.event,
hide: true,
});
assert.equal(g.decision, null);
assert.deepEqual(g.battle!.preparation, preparation);
g = applyAction(g, preparation!.owner, { type: 'declineBattlePower' });
assert.notDeepEqual(g.battle?.preparation, preparation);
assert.equal(g.discard.filter((card) => card.id === ghola.id).length, 1);
});
+507
View File
@@ -0,0 +1,507 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import {
initializeLeaderSkillsGameForAudit,
type Action,
type Game,
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function assertSkillCustody(game: Game): void {
assert.ok(game.leaderSkills);
const physical = [
...game.leaderSkills.deck,
...game.leaderSkills.assignments.map((assignment) => assignment.skill),
...Object.values(game.leaderSkills.offers).flatMap((offer) => offer.cards),
];
const canonical = LEADER_SKILL_CARDS.map((card) => card.id);
assert.equal(physical.length, 14);
assert.equal(new Set(physical).size, 14);
assert.deepEqual([...physical].sort(), [...canonical].sort());
}
function concurrentBarrier() {
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
return {
wait: async () => {
arrivals += 1;
if (arrivals === 2) release();
await gate;
},
count: () => arrivals,
};
}
async function fixture() {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Leader Skills host',
'beneGesserit',
false,
[],
);
const code = created.view.code;
const tokens = [created.token!];
for (const [name, faction] of [
['Fremen seat', 'fremen'],
['Atreides seat', 'atreides'],
] as const)
tokens.push((await store.rooms.joinRoom(code, name, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
for (const auth of auths) {
const room = await store.restart().readRoom(code);
await store
.restart()
.act(code, auth, room.version, { type: 'ready' }, clock);
}
const ready = await store.restart().readRoom(code);
const initialized = initializeLeaderSkillsGameForAudit(ready);
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initialized), initialized.version, code);
return { ...store, sqlite, code, tokens, auths };
}
async function act(
f: Awaited<ReturnType<typeof fixture>>,
player: number,
action: Action,
) {
const before = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.auths[player], before.version, action, clock);
return f.restart().readRoom(f.code);
}
void test('SQLite restart keeps each private two-card offer and one concurrent selection consumes one card once', async () => {
const f = await fixture();
try {
let game = await f.restart().readRoom(f.code);
assert.equal(game.status, 'setup');
assert.equal(game.setupStage, 'prediction');
game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(game.setupStage, 'leaderSkills');
assertSkillCustody(game);
const initialOffers = structuredClone(game.leaderSkills!.offers);
assert.equal(Object.keys(initialOffers).length, 3);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const owner = auth.playerId;
assert.deepEqual(seat.leaderSkills?.offer, initialOffers[owner]);
assert.equal(seat.leaderSkills?.assignments.length, 0);
assert.equal(seat.leaderSkills?.eligibleLeaders.length, 5);
for (const other of f.auths.filter((candidate) => candidate !== auth))
assert.notDeepEqual(
seat.leaderSkills?.offer?.cards,
initialOffers[other.playerId].cards,
);
assert.equal('deck' in seat.leaderSkills!, false);
assert.equal(seat.players[index].hand?.length, 1);
}
const owner = f.auths[0];
const ownerView = await f.restart().readSeatView(f.code, owner);
const offer = ownerView.leaderSkills!.offer!;
const chosenSkill = offer.cards[0];
const returnedSkill = offer.cards[1];
const chosenLeader = ownerView.leaderSkills!.eligibleLeaders[0].id;
const choice: Action = {
type: 'leaderSkill',
event: offer.event,
skill: chosenSkill,
leader: chosenLeader,
};
const beforeVersion = game.version;
const barrier = concurrentBarrier();
f.writes.length = 0;
f.hooks.beforeWrite = barrier.wait;
const results = await Promise.allSettled([
f.rooms.act(f.code, owner, beforeVersion, choice, clock),
f.restart().act(f.code, owner, beforeVersion, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(barrier.count(), 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
results.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const selected = await f.restart().readRoom(f.code);
assert.equal(selected.version, beforeVersion + 1);
assert.equal(selected.leaderSkills!.offers[owner.playerId], undefined);
assert.deepEqual(
selected.leaderSkills!.assignments.filter(
(assignment) => assignment.owner === owner.playerId,
),
[
{
owner: owner.playerId,
leader: chosenLeader,
skill: chosenSkill,
},
],
);
assert.equal(
selected.leaderSkills!.deck.filter((skill) => skill === returnedSkill)
.length,
1,
);
assertSkillCustody(selected);
const ownerAfter = await f.restart().readSeatView(f.code, owner);
const nextAfter = await f.restart().readSeatView(f.code, f.auths[1]);
assert.equal(ownerAfter.leaderSkills?.offer, null);
assert.deepEqual(
nextAfter.leaderSkills?.offer,
initialOffers[f.auths[1].playerId],
);
assert.deepEqual(
ownerAfter.leaderSkills?.assignments,
nextAfter.leaderSkills?.assignments,
);
const snapshot = structuredClone(selected);
await assert.rejects(
f.restart().act(f.code, owner, beforeVersion, choice, clock),
);
assert.deepEqual(await f.restart().readRoom(f.code), snapshot);
} finally {
f.sqlite.close();
}
});
void test('real setup completion preserves prediction, starting hands, assignments and traitor choices across JSON restart', async () => {
const f = await fixture();
try {
let game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
assert.equal(game.setupStage, 'leaderSkills');
const prediction = structuredClone(game.players[0].prediction);
const startingHands = game.players.map((player) =>
player.hand.map((card) => card.id),
);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const offer = seat.leaderSkills!.offer!;
game = await act(f, index, {
type: 'leaderSkill',
event: offer.event,
skill: offer.cards[0],
leader: seat.leaderSkills!.eligibleLeaders[0].id,
});
assertSkillCustody(game);
}
assert.equal(game.setupStage, 'traitors');
const assignments = structuredClone(game.leaderSkills!.assignments);
for (const [index] of f.auths.entries()) {
game = await f.restart().readRoom(f.code);
const choice = game.players[index].traitorChoices[0];
assert.ok(choice);
game = await act(f, index, { type: 'traitor', leader: choice });
}
assert.equal(game.setupStage, 'forces');
const traitors = game.players.map((player) => [...player.traitors]);
game = await act(f, 1, {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(game.status, 'playing');
assert.equal(game.setupStage, undefined);
assert.deepEqual(game.players[0].prediction, prediction);
assert.deepEqual(
game.players.map((player) => player.hand.map((card) => card.id)),
startingHands,
);
assert.deepEqual(
game.players.map((player) => player.traitors),
traitors,
);
assert.deepEqual(game.leaderSkills!.assignments, assignments);
assert.deepEqual(game.leaderSkills!.offers, {});
assertSkillCustody(game);
const persisted = structuredClone(game);
assert.deepEqual(await f.restart().readRoom(f.code), persisted);
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
assert.deepEqual(
seat.leaderSkills?.assignments,
assignments.map((assignment) => ({
...assignment,
controller: assignment.owner,
captured: false,
faceUp: true,
})),
);
assert.equal(seat.leaderSkills?.offer, null);
assert.deepEqual(
seat.players[index].hand?.map((card) => card.id),
startingHands[index],
);
for (const [otherIndex, player] of seat.players.entries())
if (otherIndex !== index) assert.equal(player.hand, undefined);
}
} finally {
f.sqlite.close();
}
});
void test('battle death and Ghola revival preserve one private replacement skill through restart and concurrent selection', async () => {
const f = await fixture();
try {
let game = await act(f, 0, {
type: 'predict',
faction: 'atreides',
turn: 4,
});
for (const [index, auth] of f.auths.entries()) {
const seat = await f.restart().readSeatView(f.code, auth);
const offer = seat.leaderSkills!.offer!;
game = await act(f, index, {
type: 'leaderSkill',
event: offer.event,
skill: offer.cards[0],
leader: seat.leaderSkills!.eligibleLeaders[0].id,
});
}
for (const [index] of f.auths.entries()) {
game = await f.restart().readRoom(f.code);
game = await act(f, index, {
type: 'traitor',
leader: game.players[index].traitorChoices[0],
});
}
game = await act(f, 1, {
type: 'fremenSetup',
placements: { sietch_tabr: 10 },
});
assert.equal(game.status, 'playing');
const atreidesAuth = f.auths[2];
const bgAuth = f.auths[0];
const atreides = game.players.find(
(player) => player.id === atreidesAuth.playerId,
)!;
const bg = game.players.find((player) => player.id === bgAuth.playerId)!;
const oldAssignment = game.leaderSkills!.assignments.find(
(assignment) => assignment.owner === atreides.id,
)!;
for (const player of game.players) {
game.deck.push(...player.hand);
player.hand = [];
player.reserves += Object.values(player.forces).reduce(
(sum, amount) => sum + amount,
0,
);
player.forces = {};
}
const gholaIndex = game.deck.findIndex((card) => card.effect === 'ghola');
const poisonIndex = game.deck.findIndex((card) => card.kind === 'poison');
assert.ok(gholaIndex >= 0 && poisonIndex >= 0);
const [ghola] = game.deck.splice(gholaIndex, 1);
const adjustedPoisonIndex = game.deck.findIndex(
(card) => card.kind === 'poison',
);
const [poison] = game.deck.splice(adjustedPoisonIndex, 1);
atreides.hand.push(ghola);
bg.hand.push(poison);
atreides.reserves -= 5;
atreides.forces['arrakeen:10'] = 5;
bg.reserves -= 5;
bg.forces['arrakeen:10'] = 5;
Object.assign(game, {
phase: 6,
storm: 18,
order: [atreides.id, bg.id, f.auths[1].playerId],
active: atreides.id,
ready: [],
decision: null,
response: null,
phaseOpening: null,
});
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
game = await act(f, 2, {
type: 'chooseBattle',
territory: 'arrakeen',
target: bg.id,
});
while (game.decision?.kind === 'leaderSkillVisibility') {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
);
assert.ok(owner >= 0);
game = await act(f, owner, {
type: 'leaderSkillVisibility',
event: game.decision.event,
hide: true,
});
}
while (game.battle?.preparation) {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.battle!.preparation!.owner,
);
assert.ok(owner >= 0);
game = await act(f, owner, { type: 'declineBattlePower' });
}
game = await act(f, 2, {
type: 'battlePlan',
dial: 0,
leader: oldAssignment.leader,
});
const bgLeader = game.leaderSkills!.assignments.find(
(assignment) => assignment.owner === bg.id,
)!.leader;
game = await act(f, 0, {
type: 'battlePlan',
dial: 1,
leader: bgLeader,
weapon: poison.id,
});
game = await act(f, 2, { type: 'traitorCall', call: false });
game = await act(f, 0, { type: 'traitorCall', call: false });
const dead = game.players
.find((player) => player.id === atreides.id)!
.leaders.find((leader) => leader.id === oldAssignment.leader)!;
assert.equal(dead.dead, true);
assert.equal(
game.leaderSkills!.assignments.some(
(assignment) => assignment.owner === atreides.id,
),
false,
);
assert.equal(
game.leaderSkills!.deck.filter((skill) => skill === oldAssignment.skill)
.length,
1,
);
assertSkillCustody(game);
if (game.decision?.kind === 'battleCards')
game = await act(
f,
f.auths.findIndex((auth) => auth.playerId === game.decision!.player),
{ type: 'decision', discard: [] },
);
game = await act(f, 2, {
type: 'card',
card: ghola.id,
leader: oldAssignment.leader,
});
assert.equal(game.decision?.kind, 'leaderSkillRevival');
assert.equal(game.discard.filter((card) => card.id === ghola.id).length, 1);
const undrawn = await f.restart().readSeatView(f.code, atreidesAuth);
assert.deepEqual(undrawn.leaderSkills!.offer!.cards, []);
assert.equal(
(await f.restart().readSeatView(f.code, bgAuth)).leaderSkills!.offer,
null,
);
const event = undrawn.leaderSkills!.offer!.event;
game = await act(f, 2, {
type: 'leaderSkill',
event,
mode: 'draw',
});
const drawn = await f.restart().readSeatView(f.code, atreidesAuth);
const cards = [...drawn.leaderSkills!.offer!.cards];
assert.equal(cards.length, 2);
assert.equal(
(await f.restart().readSeatView(f.code, bgAuth)).leaderSkills!.offer,
null,
);
const choice: Action = {
type: 'leaderSkill',
event,
skill: cards[0],
leader: oldAssignment.leader,
};
const beforeVersion = game.version;
const barrier = concurrentBarrier();
f.writes.length = 0;
f.hooks.beforeWrite = barrier.wait;
const results = await Promise.allSettled([
f.rooms.act(f.code, atreidesAuth, beforeVersion, choice, clock),
f.restart().act(f.code, atreidesAuth, beforeVersion, choice, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(barrier.count(), 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
results.filter((result) => result.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const selected = await f.restart().readRoom(f.code);
assert.equal(selected.version, beforeVersion + 1);
assert.deepEqual(
selected.leaderSkills!.assignments.filter(
(assignment) => assignment.owner === atreides.id,
),
[
{
owner: atreides.id,
leader: oldAssignment.leader,
skill: cards[0],
},
],
);
assert.equal(
selected.leaderSkills!.deck.filter((skill) => skill === cards[0]).length,
0,
);
assert.equal(
selected.leaderSkills!.deck.filter((skill) => skill === cards[1]).length,
1,
);
assertSkillCustody(selected);
const snapshot = structuredClone(selected);
await assert.rejects(
f.restart().act(f.code, atreidesAuth, selected.version, choice, clock),
);
assert.deepEqual(await f.restart().readRoom(f.code), snapshot);
} finally {
f.sqlite.close();
}
});
+414
View File
@@ -0,0 +1,414 @@
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,
);
});
+51 -1
View File
@@ -2,7 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { createGame, joinGame, newPlayer } from '../game/engine';
import { startIxPrototypeRoom } from '../tools/prototype-room';
import {
startIxPrototypeRoom,
startPrototypeRoom,
} from '../tools/prototype-room';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
@@ -96,3 +99,50 @@ void test('unready, wrong identity and incompatible lobbies fail without writing
);
}
});
void test('Leader Skills prototype saves the real private setup once without changing other rooms or seats', (t) => {
const { db } = fixture(t);
const game = createGame(
'PROTOTYP',
newPlayer('i', 'Atreides', 'atreides'),
true,
);
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
game.players.forEach((p) => {
p.ready = true;
});
db.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(
JSON.stringify(game),
game.code,
);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db
.prepare('SELECT * FROM rooms WHERE code = ?')
.get('KEEPME00');
const result = startPrototypeRoom(db, game.code, 7, 'leader-skills');
assert.equal(result.version, 8);
const saved = JSON.parse(
db.prepare('SELECT state FROM rooms WHERE code = ?').get(game.code)!
.state as string,
);
assert.equal(saved.version, 8);
assert.equal(saved.setupStage, 'leaderSkills');
assert.equal(saved.leaderSkills.offers.i.cards.length, 2);
assert.equal(saved.leaderSkills.offers.h.cards.length, 2);
assert.equal(saved.players[0].hand.length, 1);
assert.equal(saved.players[1].hand.length, 2);
const rooms = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(
() => startPrototypeRoom(db, game.code, 8, 'leader-skills'),
/redeal/,
);
assert.deepEqual(
db.prepare('SELECT * FROM rooms ORDER BY code').all(),
rooms,
);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(
db.prepare('SELECT * FROM rooms WHERE code = ?').get('KEEPME00'),
other,
);
});