Connect Suk Graduate casualty rescues and saved continuation

Implement both rescue bands with legal physical casualty routing, owner controls,
all four bot profiles and durable battle receipts. Preserve paid support, elite
identity, killed/returned leader custody and replay rejection. New Advanced
Atreides skill choices exclude Suk while the KH loss-count ruling is pending;
legacy assigned combinations retain their preflight guard. Public gates remain.

Independent review fixed stale dead trainer assignments and detached rescue
receipts. Final focused Suk/Leader Skills union: 46 passed. Typecheck, lint,
4,452 offline tests, production build and 43 HTTP tests passed on unchanged
source tree 3b76239a46d0ba874299495e64295a5eaaa8f2415c3e9a041bee944bbce50c5a.

Actual browser rescue and refresh retained counters, private hand and pending
winner card cleanup. Two four-profile Basic/Advanced smoke games completed
with JSON restoration and no rejected actions; neither exercised a Suk rescue.
After the outage report the server was healthy. All 360 pre-check rooms and
original seat/recovery records are unchanged; checks added 33 rooms. SQLite
integrity and foreign-key checks pass. No server restart or database reset.
This commit is contained in:
Quintine committed 2026-09-13 19:02:32 +10:00
1 parent 5082c81353
commit af360c836b
23 files changed
+987 -26

No files matched your search

+20
View File
@@ -129,6 +129,26 @@ void test('a private setup offer renders only its two physical cards and eligibl
assert.equal(disabledControls(disabled), 4);
});
void test('an unavailable offered skill remains inspectable while the other card is selected', () => {
const reason = 'Advanced Atreides awaits the Kwisatz Haderach loss-count ruling.';
const html = markup(view({
offer: { event: 'guarded-offer', cards: ['suk-graduate', 'warmaster'], leader: null },
eligibleLeaders: [leaders[0]],
unavailableSkills: { 'suk-graduate': reason },
}));
const blocked = html.match(/<input\b[^>]*value="suk-graduate"[^>]*>/)?.[0];
const available = html.match(/<input\b[^>]*value="warmaster"[^>]*>/)?.[0];
assert.ok(blocked && available);
assert.match(blocked, /disabled=""/);
assert.doesNotMatch(blocked, /checked=""/);
assert.match(available, /checked=""/);
assert.doesNotMatch(available, /disabled=""/);
assert.ok(html.includes(reason));
assert.match(html, /Inspect Suk Graduate/);
assert.match(html, /Assign selected skill/);
assert.equal(disabledControls(html), 1, 'the legal assignment remains usable');
});
void test('revival asks before preview and does not offer a decline after cards are drawn', () => {
const undrawn = markup(
view({
+1 -1
View File
@@ -509,6 +509,6 @@ void test('a skilled disc that dies between traitor votes cannot supply a stale
game,
'd',
{ type: 'traitorCall', call: false },
/assigned Planetologist leader’s weapon/,
/dead or missing leader/,
);
});
+1 -1
View File
@@ -353,7 +353,7 @@ void test('captured or dead Planetologist gives neither side a native movement b
delete leader.capturedBy;
leader.dead = true;
assert.equal(planetologistLeader(g, 'p'), null);
unchanged(g, move(2, 'range'), /available Planetologist/);
unchanged(g, move(2, 'range'), /dead or missing leader/);
});
void test('Bene Gesserit gather resolves determined mixed-source stances and preserves advisor locks', () => {
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SukGraduatePanel, sukRescueLabel } from '../components/suk-graduate';
void test('Suk controls explain physical destinations, offer decline only where legal, and require a choice', () => {
const options = [{ normal: 0, elite: 0, kept: null },
{ normal: 2, elite: 1, kept: { key: 'wind_pass:14', kind: 'elite' as const } }];
const markup = renderToStaticMarkup(createElement(SukGraduatePanel, {
decision: { kind: 'sukRescue', player: 'a', event: 'battle-1', territory: 'wind_pass', mode: 'skilled', options },
act: () => { throw new Error('render must not submit'); }, busy: true,
}));
assert.match(markup, /Forces to save with Suk Graduate/);
assert.match(markup, /Do not rescue any forces/);
assert.match(markup, /keep 1 elite in sector 14; return 2 to reserves/);
assert.match(markup, /paid battle support is unchanged/);
assert.match(markup, /<button[^>]*disabled/);
assert.equal(sukRescueLabel({ normal: 1, elite: 0, kept: null }), 'Save 1 ordinary + 0 elite: return 1 to reserves');
});
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
function dealtSukGame(native: 'emperor' | 'atreides' | 'harkonnen', advanced: boolean): Game {
let game = createGame('SUKTEST', newPlayer('a', 'Rescuer', native), advanced);
joinGame(game, newPlayer('d', 'Opponent', 'guild'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'suk-graduate');
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = shuffleIndex-- === index ? 0 : 0xffffffff;
return array;
});
try { game = initializeLeaderSkillsGameForAudit(game); }
finally { mock.restoreAll(); }
assert.equal(game.leaderSkills!.offers.a.cards[0], 'suk-graduate');
return game;
}
export function advancedAtreidesSukOffer(): Game {
return dealtSukGame('atreides', true);
}
/** Genuine module setup, then a conserved, explicitly staged battle position. */
export function sukBattle({ hide = true, advanced = false, elite = false, bluff = false, captured = false, weaponKills = false, dial = 4, atreides = false } = {}): Game {
const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor';
let game = dealtSukGame(native, advanced);
for (const id of ['a', 'd']) {
const offer = game.leaderSkills!.offers[id];
const skill = id === 'a' && advanced && atreides ? offer.cards[1] : offer.cards[0];
game = applyAction(game, id, { type: 'leaderSkill', event: offer.event,
skill, leader: id === 'a' ? `${native}-0` : 'guild-0' });
}
if (advanced && atreides) {
// Stage an old saved assignment only after setup legally returned Suk to the deck.
const assignment = game.leaderSkills!.assignments.find((a) => a.owner === 'a')!;
const sukIndex = game.leaderSkills!.deck.indexOf('suk-graduate');
assert.ok(sukIndex >= 0);
game.leaderSkills!.deck[sukIndex] = assignment.skill;
assignment.skill = 'suk-graduate';
validateLeaderSkills(game.leaderSkills!, game.players);
}
for (let i = 0; game.status === 'setup' && i < 10; i++) {
const owner = game.players.find((p) => p.traitorChoices.length);
assert.ok(owner);
game = applyAction(game, owner.id, { type: 'traitor', leader: owner.traitorChoices[0] });
}
assert.equal(game.status, 'playing');
if (captured) {
const [own, other] = game.leaderSkills!.assignments;
[own.skill, other.skill] = [other.skill, own.skill];
game.players[1].leaders[0].capturedBy = 'a';
}
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.forces = { 'arrakeen:10': 5 }; player.reserves = 15; player.tanks = 0;
player.spice = 20; player.battleLosses = 0;
if (player.elites) player.elites = { forces: {}, reserves: 5, tanks: 0, revived: 0 };
}
if (elite) game.players[0].elites = { forces: { 'arrakeen:10': 2 }, reserves: 3, tanks: 0, revived: 0 };
const cardIndex = game.deck.findIndex((card) => card.kind === 'worthless');
assert.ok(cardIndex >= 0);
const [played] = game.deck.splice(cardIndex, 1);
game.players[0].hand.push(played);
let enemyWeapon: string | undefined;
if (weaponKills) {
const index = game.deck.findIndex((card) => card.kind === 'projectile');
assert.ok(index >= 0);
const [weapon] = game.deck.splice(index, 1);
game.players[1].hand.push(weapon); enemyWeapon = weapon.id;
}
Object.assign(game, { phase: 6, storm: 18, order: ['a', 'd'], active: 'a', ready: [], decision: null, response: null, phaseOpening: null });
game = applyAction(game, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'd' });
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event,
hide: decision.player === 'a' ? hide : false });
}
while (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0,
leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id });
game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon });
game = applyAction(game, 'a', { type: 'traitorCall', call: false });
return game;
}
export function resolveSuk(game: Game): Game {
return applyAction(game, 'd', { type: 'traitorCall', call: false });
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { resolveSuk, sukBattle } from './suk-graduate-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restart preserves Suk rescue and concurrent submissions save physical counters exactly once', async () => {
const f = unitStore();
const sqlite: DatabaseSync = f.sqlite;
try {
const created = await f.rooms.createRoom('Rescuer', 'emperor', false, []);
const code = created.view.code;
const joined = await f.rooms.joinRoom(code, 'Opponent', 'guild');
const tokens = [created.token!, joined.token!];
const old = await Promise.all(tokens.map((token) => f.rooms.authenticate(code, token)));
for (const [i, auth] of old.entries())
sqlite.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?').run(i ? 'd' : 'a', code, auth.playerId);
const auths = await Promise.all(tokens.map((token) => f.restart().authenticate(code, token)));
const state = resolveSuk(sukBattle());
state.code = code;
state.version = (await f.rooms.readRoom(code)).version;
sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=?').run(JSON.stringify(state), state.version, code);
const own = await f.restart().readSeatView(code, auths[0]);
const other = await f.restart().readSeatView(code, auths[1]);
assert.equal(own.decision?.kind, 'sukRescue');
assert.deepEqual(own.decision, other.decision, 'public casualty routing reveals no private hand');
assert.equal('pendingSukRescue' in other, false);
assert.equal(other.players.find((p) => p.id === 'a')!.hand, undefined);
assert.deepEqual(await f.restart().readRoom(code), state);
const decision = own.decision;
if (decision?.kind !== 'sukRescue') throw new Error('Missing rescue');
const choice = decision.options.findIndex((o) => o.normal + o.elite === 3);
let arrivals = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
const action = { type: 'decision' as const, event: decision.event, choice };
const results = await Promise.allSettled([
f.rooms.act(code, auths[0], state.version, action, clock),
f.restart().act(code, auths[0], state.version, action, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(results.filter((r) => r.status === 'rejected').length, 1);
const after = await f.restart().readRoom(code);
assert.equal(after.version, state.version + 1);
assert.equal(after.pendingSukRescue, null);
assert.equal(after.players[0].reserves, 17);
assert.equal(after.players[0].tanks, 1);
assert.equal(after.players[0].forces['arrakeen:10'], 2);
await assert.rejects(() => f.restart().act(code, auths[0], state.version, action, clock));
assert.deepEqual(await f.restart().readRoom(code), after);
} finally { sqlite.close(); }
});
+286
View File
@@ -0,0 +1,286 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { sukGraduateSkill, sukRescueOptions, quoteSukRescue } from '../game/suk-graduate';
import { validateLeaderSkills } from '../game/leader-skills';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { advancedAtreidesSukOffer, sukBattle, resolveSuk } from './suk-graduate-fixture';
function conserved(game: Game) {
for (const player of game.players) {
assert.equal(Object.values(player.forces).reduce((a, b) => a + b, 0) + player.reserves + player.tanks, 20);
if (player.elites) assert.equal(Object.values(player.elites.forces).reduce((a, b) => a + b, 0) + player.elites.reserves + player.elites.tanks, 5);
}
validateLeaderSkills(game.leaderSkills!, game.players);
}
function unchanged(game: Game, player: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, player, action));
assert.equal(JSON.stringify(game), before);
}
function chooseRescue(game: Game, count: number, keepElite = false): Game {
const d = game.decision;
assert.equal(d?.kind, 'sukRescue');
if (d?.kind !== 'sukRescue') throw new Error('Missing rescue');
const choice = d.options.findIndex((o) => o.normal + o.elite === count && (!keepElite || o.kept?.kind === 'elite'));
assert.ok(choice >= 0);
return applyAction(game, 'a', { type: 'decision', event: d.event, choice });
}
void test('Suk entitlement distinguishes public, selected, bluff, killed and captured leaders', () => {
const assignment = { skill: 'suk-graduate' as const, leader: 'skilled', faceUp: true, captured: false };
assert.equal(sukGraduateSkill([assignment], { id: 'other', dead: false }, false)?.mode, 'normal');
assert.equal(sukGraduateSkill([{ ...assignment, faceUp: false }], { id: 'other', dead: false }, true), null);
assert.equal(sukGraduateSkill([{ ...assignment, faceUp: false }], { id: 'skilled', dead: false }, false), null);
assert.equal(sukGraduateSkill([{ ...assignment, captured: true }], { id: 'other', dead: false }, true), null);
assert.equal(sukGraduateSkill([{ ...assignment, captured: true }], { id: 'skilled', dead: false }, true)?.mode, 'skilled');
});
void test('new Advanced Atreides setup and revival keep the physical Suk offer private but reject its assignment immutably', () => {
const game = advancedAtreidesSukOffer();
const ownerView = viewGame(game, 'a');
const otherView = viewGame(game, 'd');
const offer = ownerView.leaderSkills!.offer!;
assert.equal(offer.cards.length, 2);
assert.ok(offer.cards.includes('suk-graduate'));
assert.match(
ownerView.leaderSkills!.unavailableSkills?.['suk-graduate'] ?? '',
/Kwisatz Haderach loss-count ruling is pending/,
);
assert.equal(otherView.leaderSkills?.unavailableSkills, undefined);
const available = offer.cards.find((skill) => skill !== 'suk-graduate')!;
const blockedAction: Action = {
type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: 'atreides-0',
};
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, 'a', blockedAction), /Suk Graduate is unavailable/);
assert.equal(JSON.stringify(game), before);
validateLeaderSkills(game.leaderSkills!, game.players);
const assigned = applyAction(game, 'a', { ...blockedAction, skill: available });
assert.equal(assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill, available);
assert.ok(assigned.leaderSkills!.deck.includes('suk-graduate'));
validateLeaderSkills(assigned.leaderSkills!, assigned.players);
const revival = structuredClone(game);
revival.status = 'playing';
revival.setupStage = undefined;
revival.leaderSkills!.offers.a.leader = 'atreides-0';
revival.decision = { kind: 'leaderSkillRevival', player: 'a', event: offer.event };
const revivalBefore = JSON.stringify(revival);
assert.throws(() => applyAction(revival, 'a', blockedAction), /Suk Graduate is unavailable/);
assert.equal(JSON.stringify(revival), revivalBefore);
validateLeaderSkills(revival.leaderSkills!, revival.players);
});
void test('all four Advanced Atreides bots choose the available other skill', () => {
for (const difficulty of DIFFICULTIES) {
const game = advancedAtreidesSukOffer();
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const offer = view.leaderSkills!.offer!;
const available = offer.cards.find((skill) => skill !== 'suk-graduate')!;
const action = botActions(view)[0];
assert.equal(action?.type, 'leaderSkill', difficulty);
assert.equal(action?.skill, available, difficulty);
const assigned = applyAction(game, 'a', action);
assert.equal(assigned.leaderSkills!.assignments.find((a) => a.owner === 'a')?.skill, available);
validateLeaderSkills(assigned.leaderSkills!, assigned.players);
}
});
void test('Suk redirects only selected physical casualties and keeps the original sector', () => {
const pool = [{ key: 'wind_pass:14', normal: 2, elite: 1 }, { key: 'wind_pass:15', normal: 1, elite: 1 }];
const losses = { normal: 2, elite: 2, paidNormal: 2, paidElite: 2 };
const skill = { leader: 'skilled', mode: 'skilled' as const };
const options = sukRescueOptions(skill, pool, losses);
assert.ok(options.some((o) => o.normal + o.elite === 0));
assert.ok(options.every((o) => o.normal + o.elite <= 3));
assert.ok(options.every((o) => o.kept?.key !== 'wind_pass:15' || o.kept.kind === 'elite'));
for (const option of options) {
const q = quoteSukRescue(skill, pool, losses, option);
assert.equal(q.removed.reduce((n, g) => n + g.normal + g.elite, 0), 4 - (option.kept ? 1 : 0));
assert.equal(q.reserves.normal + q.reserves.elite + q.tanks.normal + q.tanks.elite + (option.kept ? 1 : 0), 4);
}
const normal = sukRescueOptions({ ...skill, mode: 'normal' }, pool, losses);
assert.equal(normal.length, 2);
assert.ok(normal.every((o) => o.normal + o.elite === 1 && !o.kept));
assert.throws(() => quoteSukRescue(skill, pool, losses, { normal: 4, elite: 0, kept: { key: 'wind_pass:14', kind: 'normal' } }));
});
void test('Basic normal rescue is automatic; concealed bluff grants no rescue', () => {
const normal = resolveSuk(sukBattle({ hide: false }));
assert.equal(normal.pendingSukRescue, null);
assert.equal(normal.players[0].tanks, 3);
assert.equal(normal.players[0].reserves, 16);
assert.equal(normal.players[0].forces['arrakeen:10'], 1);
conserved(normal);
const bluff = resolveSuk(sukBattle({ bluff: true }));
assert.equal(bluff.pendingSukRescue, undefined);
assert.equal(bluff.players[0].tanks, 4);
assert.equal(bluff.players[0].reserves, 15);
conserved(bluff);
});
void test('skilled rescue saves up to three without stacking, and survives JSON with strict ownership', () => {
const pending = resolveSuk(sukBattle());
assert.equal(pending.decision?.kind, 'sukRescue');
assert.equal(pending.players[0].tanks, 0, 'raw casualties remain pending');
assert.equal(pending.players[0].forces['arrakeen:10'], 5);
const restored = JSON.parse(JSON.stringify(pending)) as Game;
assert.deepEqual(normalizeAutomaticGame(restored), restored);
assert.deepEqual(viewGame(restored, 'a').decision, viewGame(pending, 'a').decision);
unchanged(restored, 'd', { type: 'decision', event: restored.pendingSukRescue!.event, choice: 1 });
unchanged(restored, 'a', { type: 'decision', event: 'stale', choice: 1 });
unchanged(restored, 'a', { type: 'decision', event: restored.pendingSukRescue!.event, choice: 100 });
const done = chooseRescue(restored, 3);
assert.equal(done.players[0].tanks, 1);
assert.equal(done.players[0].reserves, 17);
assert.equal(done.players[0].forces['arrakeen:10'], 2);
assert.equal(done.players[0].spice, 20);
assert.equal(done.lastBattleContext?.sukRescue?.completed, true);
assert.equal(done.pendingSukRescue, null);
conserved(done);
unchanged(done, 'a', { type: 'decision', event: restored.pendingSukRescue!.event, choice: 3 });
const declined = chooseRescue(pending, 0);
assert.equal(declined.players[0].tanks, 4);
assert.equal(declined.players[0].reserves, 15);
assert.equal(declined.players[0].forces['arrakeen:10'], 1);
conserved(declined);
});
void test('corrupt Suk receipt, force pool, options or missing continuation fail without mutation', () => {
const good = resolveSuk(sukBattle());
for (const corrupt of [
(g: Game) => { g.pendingSukRescue = null; },
(g: Game) => { g.players[0].reserves++; },
(g: Game) => { g.pendingSukRescue!.skill.mode = 'normal'; },
(g: Game) => { g.pendingSukRescue!.losses!.normal--; },
(g: Game) => { if (g.decision?.kind === 'sukRescue') g.decision.options.pop(); },
(g: Game) => { g.decision = null; },
(g: Game) => {
const assignment = g.leaderSkills!.assignments.find((a) => a.skill === 'suk-graduate')!;
assignment.skill = g.leaderSkills!.deck[0];
g.leaderSkills!.deck[0] = 'suk-graduate';
},
]) {
const bad = structuredClone(good); corrupt(bad);
const before = JSON.stringify(bad);
assert.throws(() => viewGame(bad, 'a'));
assert.throws(() => normalizeAutomaticGame(bad));
unchanged(bad, 'a', { type: 'advanceBots' });
assert.equal(JSON.stringify(bad), before);
}
});
void test('a stale dead face-up trainer cannot rescue casualties or appear as an active skill', () => {
const game = sukBattle({ hide: false });
game.players[0].leaders[0].dead = true;
const before = JSON.stringify(game);
assert.throws(() => viewGame(game, 'a'), /dead or missing leader/);
assert.throws(() => normalizeAutomaticGame(game), /dead or missing leader/);
unchanged(game, 'd', { type: 'traitorCall', call: false });
assert.equal(JSON.stringify(game), before);
});
void test('Advanced rescue preserves paid support, elite identity and all four legal AI paths', () => {
let pending = resolveSuk(sukBattle({ advanced: true, elite: true }));
if (pending.decision?.kind === 'battleLosses') {
const choice = pending.decision.options.findIndex((o) => o.elite > 0 && o.normal > 0);
assert.ok(choice >= 0);
pending = applyAction(pending, 'a', { type: 'decision', choice });
}
assert.equal(pending.decision?.kind, 'sukRescue');
const done = chooseRescue(pending, 3, true);
assert.equal(done.players[0].spice, 17, 'support remains paid for raw dial');
assert.equal(done.players[0].tanks, 0);
assert.equal(done.players[0].elites?.forces['arrakeen:10'], 2);
assert.equal(done.players[0].elites?.reserves, 3);
conserved(done);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'a');
view.players.find((p) => p.id === 'a')!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, difficulty);
const result = applyAction(pending, 'a', actions[0]);
assert.equal(result.pendingSukRescue, null);
assert.equal(result.players[0].tanks, 0);
conserved(result);
}
});
void test('used captive returns with its skill before the captor resolves the frozen lower-band rescue', () => {
const pending = resolveSuk(sukBattle({ captured: true }));
assert.equal(pending.decision?.kind, 'sukRescue');
assert.equal(pending.pendingSukRescue?.skill.mode, 'skilled');
assert.equal(pending.players[1].leaders[0].capturedBy, undefined);
assert.equal(pending.leaderSkills!.assignments.find((a) => a.skill === 'suk-graduate')?.owner, 'd');
const done = chooseRescue(JSON.parse(JSON.stringify(pending)) as Game, 3);
assert.equal(done.players[0].tanks, 1);
assert.equal(done.players[0].reserves, 17);
conserved(done);
});
void test('a killed skilled winner gets no rescue, but a separate face-up trainer still rescues', () => {
const killed = resolveSuk(sukBattle({ weaponKills: true }));
assert.equal(killed.lastBattleContext?.winner, 'a');
assert.equal(killed.players[0].leaders[0].dead, true);
assert.equal(killed.pendingSukRescue, undefined);
assert.equal(killed.players[0].tanks, 4);
conserved(killed);
const otherKilled = resolveSuk(sukBattle({ hide: false, weaponKills: true }));
assert.equal(otherKilled.players[0].leaders[1].dead, true);
assert.equal(otherKilled.players[0].leaders[0].dead, false);
assert.equal(otherKilled.players[0].tanks, 3);
assert.equal(otherKilled.players[0].reserves, 16);
conserved(otherKilled);
});
void test('zero dial and a traitor victory have no Suk loss or optional window', () => {
const zero = resolveSuk(sukBattle({ dial: 0 }));
assert.equal(zero.pendingSukRescue, undefined);
assert.equal(zero.players[0].tanks, 0);
const state = sukBattle();
state.battle!.traitorCalls.a = undefined as unknown as boolean;
state.players[0].traitors = [state.battle!.plans.d.leader!];
const called = applyAction(state, 'a', { type: 'traitorCall', call: true });
const win = resolveSuk(called);
assert.equal(win.lastBattleContext?.result, 'traitor');
assert.equal(win.pendingSukRescue, undefined);
assert.equal(win.players[0].tanks, 0);
conserved(win);
});
void test('Advanced normal rescue offers the actual casualty types and returns the chosen elite to reserves', () => {
let game = resolveSuk(sukBattle({ hide: false, advanced: true, elite: true }));
if (game.decision?.kind === 'battleLosses') {
const choice = game.decision.options.findIndex((o) => o.normal === 2 && o.elite === 1);
assert.ok(choice >= 0);
game = applyAction(game, 'a', { type: 'decision', choice });
}
const decision = game.decision;
assert.equal(decision?.kind, 'sukRescue');
if (decision?.kind !== 'sukRescue') throw new Error('Missing rescue');
assert.equal(decision.mode, 'normal');
assert.ok(decision.options.every((o) => o.normal + o.elite === 1 && !o.kept));
const choice = decision.options.findIndex((o) => o.elite === 1);
game = applyAction(game, 'a', { type: 'decision', event: decision.event, choice });
assert.equal(game.players[0].elites?.reserves, 4);
assert.equal(game.players[0].elites?.tanks, 0);
assert.equal(game.players[0].elites?.forces['arrakeen:10'], 1);
assert.equal(game.players[0].tanks, 2);
conserved(game);
});
void test('Advanced Atreides pending ruling rejects before the final traitor vote; Basic rescue remains playable', () => {
const blocked = sukBattle({ advanced: true, atreides: true });
const before = JSON.stringify(blocked);
assert.throws(() => resolveSuk(blocked), /Kwisatz Haderach loss-count ruling/);
assert.equal(JSON.stringify(blocked), before);
const basic = resolveSuk(sukBattle({ atreides: true }));
assert.equal(basic.decision?.kind, 'sukRescue');
conserved(chooseRescue(basic, 3));
});