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:
1 parent
369371671f
commit
6e6fecc9d4
28 files changed
+4196
-38
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user