Connect Leader Skill battle effects and private Rihani exchanges

Prototype Mentat/Bureaucrat score effects, automatic Sandmaster board spice, and Rihani inspection plus committed exchange. Preserve private views, physical custody, saved winner cleanup and captive/Suk sequencing. Remaining normal bands, missing skill families and combined modules remain gated.

Validation: 45 focused tests; typecheck, lint, 4465 offline tests, production build and 43 HTTP tests passed. Independent rules/privacy/persistence review completed. New isolated browser setup exercised inspection, draw, pending refresh, exchange and completed refresh. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not trigger Rihani.

Preserved all 393 opening rooms and original seat/recovery rows (427 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported outage; no restart or reset.

Verified source tree: c64ce9537269de94c0d785bc08b17346e826c5797352a636771e1dc6ce5e4859
This commit is contained in:
Quintine committed 2026-09-13 19:45:29 +10:00
1 parent af360c836b
commit 06f46c6f79
26 files changed
+1230 -122

No files matched your search

@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import {
leaderSkillBattle,
resolveLeaderSkillBattle,
} from './leader-skill-battle-fixture';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
context,
);
},
});
const { RihaniChoice, RihaniHistory } =
await import('../components/rihani-decipherer');
aliases.deregister();
function choiceHtml(game: GameView, busy = false) {
return renderToStaticMarkup(
createElement(RihaniChoice, { game, busy, act() {} }),
);
}
function historyHtml(game: GameView) {
return renderToStaticMarkup(createElement(RihaniHistory, { game }));
}
function identityName(game: GameView, id: string) {
return game.allLeaders.find((leader) => leader.id === id)?.name ?? id;
}
void test('Rihani offer and private inspection render only for the owning seat', () => {
const pending = resolveLeaderSkillBattle(
leaderSkillBattle({ skill: 'rihani-decipherer' }),
);
const owner = viewGame(pending, 'a');
const observer = viewGame(pending, 'd');
const receipt = pending.rihaniHistory![0];
const offer = choiceHtml(owner);
assert.match(offer, /aria-label="Rihani draw offer"/);
assert.match(offer, /Once drawn, you must keep one new card/);
assert.match(offer, />Draw two Traitors<\/button>/);
assert.match(offer, />Decline exchange<\/button>/);
assert.equal(choiceHtml(observer), '');
assert.deepEqual(observer.rihani?.history, []);
assert.equal('drawn' in observer.rihani!.pending!, false);
assert.equal('eligible' in observer.rihani!.pending!, false);
const privateHistory = historyHtml(owner);
assert.match(privateHistory, /Your private Rihani inspections/);
assert.match(
privateHistory,
/Normal inspection — returned to the shuffled deck/,
);
for (const id of receipt.peeked) {
assert.ok(privateHistory.includes(identityName(owner, id)));
assert.ok(
privateHistory.includes(`Inspect traitor: ${identityName(owner, id)}`),
);
}
assert.equal(historyHtml(observer), '');
assert.equal(
(choiceHtml(owner, true).match(/<button[^>]*disabled=""/g) ?? []).length,
2,
);
});
void test('Rihani return controls separate new-card keeps from unused old-card reveals and require both selections', () => {
const offered = resolveLeaderSkillBattle(
leaderSkillBattle({ skill: 'rihani-decipherer' }),
);
const event = offered.rihaniHistory![0].event;
const drawn = applyAction(offered, 'a', {
type: 'decision',
event,
draw: true,
});
const receipt = drawn.rihaniHistory![0];
const owner = viewGame(drawn, 'a');
const observer = viewGame(drawn, 'd');
const controls = choiceHtml(owner);
assert.match(controls, /aria-label="Rihani Traitor exchange"/);
assert.equal(
(controls.match(/name="rihani-keep"/g) ?? []).length,
receipt.drawn.length,
);
assert.equal(
(controls.match(/name="rihani-give"/g) ?? []).length,
receipt.eligible.length,
);
assert.doesNotMatch(controls, /checked=""/);
assert.match(
controls,
/<button[^>]*disabled=""[^>]*>Keep and reveal selected cards<\/button>/,
);
for (const id of receipt.drawn) {
assert.ok(controls.includes(`value="${id}"`));
assert.ok(controls.includes(`Keep ${identityName(owner, id)}`));
}
for (const id of receipt.eligible) {
assert.ok(controls.includes(`value="${id}"`));
assert.ok(controls.includes(`Reveal ${identityName(owner, id)}`));
}
assert.equal(choiceHtml(observer), '');
assert.equal('drawn' in observer.rihani!.pending!, false);
assert.equal('eligible' in observer.rihani!.pending!, false);
const busy = choiceHtml(owner, true);
assert.equal(
(busy.match(/<input[^>]*disabled=""/g) ?? []).length,
receipt.drawn.length + receipt.eligible.length,
);
assert.match(
busy,
/<button[^>]*disabled=""[^>]*>Keep and reveal selected cards<\/button>/,
);
const kept = receipt.drawn[1];
const given = receipt.eligible[0];
const done = applyAction(drawn, 'a', {
type: 'decision',
event,
cards: [kept, given],
});
const completed = historyHtml(viewGame(done, 'a'));
assert.ok(completed.includes(`Kept ${identityName(owner, kept)}`));
assert.ok(completed.includes(`returned ${identityName(owner, given)}`));
assert.equal(historyHtml(viewGame(done, 'd')), '');
});
@@ -0,0 +1,153 @@
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 {
leaderSkillBattle,
resolveLeaderSkillBattle,
} from './leader-skill-battle-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restart preserves both Rihani choices and concurrent submissions change physical Traitors exactly once', async () => {
const f = unitStore();
const sqlite: DatabaseSync = f.sqlite;
try {
const created = await f.rooms.createRoom(
'Decipherer',
'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 [index, auth] of old.entries())
sqlite
.prepare(
'UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?',
)
.run(index ? 'd' : 'a', code, auth.playerId);
const auths = await Promise.all(
tokens.map((token) => f.restart().authenticate(code, token)),
);
const state = resolveLeaderSkillBattle(
leaderSkillBattle({ skill: 'rihani-decipherer' }),
);
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 ownerOffer = await f.restart().readSeatView(code, auths[0]);
const observerOffer = await f.restart().readSeatView(code, auths[1]);
assert.equal(ownerOffer.decision?.kind, 'rihani');
assert.equal(ownerOffer.rihani?.history[0].peeked.length, 2);
assert.deepEqual(observerOffer.rihani?.history, []);
assert.equal('rihaniHistory' in observerOffer, false);
assert.equal('drawn' in observerOffer.rihani!.pending!, false);
assert.equal('eligible' in observerOffer.rihani!.pending!, false);
assert.deepEqual(await f.restart().readRoom(code), state);
const event = state.rihaniHistory![0].event;
const draw = { type: 'decision' as const, event, draw: true };
let arrivals = 0;
let release!: () => void;
let barrier = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await barrier;
};
const drawResults = await Promise.allSettled([
f.rooms.act(code, auths[0], state.version, draw, clock),
f.restart().act(code, auths[0], state.version, draw, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(
drawResults.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
drawResults.filter((result) => result.status === 'rejected').length,
1,
);
const drawn = await f.restart().readRoom(code);
const receipt = drawn.rihaniHistory![0];
assert.equal(drawn.version, state.version + 1);
assert.equal(receipt.stage, 'return');
assert.equal(receipt.drawn.length, 2);
assert.equal(
drawn.players[0].traitors.length,
state.players[0].traitors.length + 2,
);
assert.equal(
drawn.traitorReserve!.length,
state.traitorReserve!.length - 2,
);
const observerReturn = await f.restart().readSeatView(code, auths[1]);
assert.equal(observerReturn.rihani?.pending?.stage, 'return');
assert.equal('drawn' in observerReturn.rihani!.pending!, false);
assert.equal('eligible' in observerReturn.rihani!.pending!, false);
const kept = receipt.drawn[0];
const given = receipt.eligible[0];
const returned = receipt.drawn[1];
const finish = {
type: 'decision' as const,
event,
cards: [kept, given],
};
arrivals = 0;
barrier = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await barrier;
};
const finishResults = await Promise.allSettled([
f.rooms.act(code, auths[0], drawn.version, finish, clock),
f.restart().act(code, auths[0], drawn.version, finish, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(
finishResults.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.equal(
finishResults.filter((result) => result.status === 'rejected').length,
1,
);
const done = await f.restart().readRoom(code);
assert.equal(done.version, state.version + 2);
assert.equal(done.rihaniHistory![0].stage, 'complete');
assert.equal(done.lastBattleContext?.rihani?.completed, true);
assert.equal(
done.players[0].traitors.length,
state.players[0].traitors.length,
);
assert.ok(done.players[0].traitors.includes(kept));
assert.ok(!done.players[0].traitors.includes(given));
assert.ok(done.traitorReserve!.includes(given));
assert.ok(done.traitorReserve!.includes(returned));
assert.equal(done.traitorReserve!.length, state.traitorReserve!.length);
await assert.rejects(() =>
f.restart().act(code, auths[0], drawn.version, finish, clock),
);
assert.deepEqual(await f.restart().readRoom(code), done);
} finally {
sqlite.close();
}
});
+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
import { leaderSkillBattle, resolveLeaderSkillBattle } from './leader-skill-battle-fixture';
import { leaderSkillStrongholdCount } from '../game/leader-skill-battle-board';
import { leaderSkillBattleBonus, bureaucratBattlePenalty } from '../game/leader-skill-combat';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { validateLeaderSkills } from '../game/leader-skills';
import { rihaniVictorySkill } from '../game/rihani-decipherer';
function reject(g: Game, id: string, action: Action) {
const before = JSON.stringify(g); assert.throws(() => applyAction(g, id, action)); assert.equal(JSON.stringify(g), before);
}
function conserved(g: Game) {
validateLeaderSkills(g.leaderSkills!, g.players);
for (const p of g.players) assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a,b) => a+b, 0), 20);
assert.equal(new Set([...(g.traitorReserve ?? []), ...g.players.flatMap((p) => p.traitors)]).size, g.players.length * 5);
}
void test('Mentat adds only surviving selected disc strength and Bureaucrat reduces the opposing total', () => {
for (const captured of [false, true]) {
const assignments = [{ skill: 'mentat' as const, leader: 'm', faceUp: captured, captured }];
assert.equal(leaderSkillBattleBonus({ assignments, selectedLeader: { id: 'm', kind: 'disc' }, weapon: undefined, defense: undefined, skilledLeaderSurvives: true }).bonus, 2);
assert.equal(leaderSkillBattleBonus({ assignments, selectedLeader: { id: 'm', kind: 'disc' }, weapon: undefined, defense: undefined, skilledLeaderSurvives: false }).bonus, 0);
assert.equal(leaderSkillBattleBonus({ assignments, selectedLeader: { id: 'other', kind: 'disc' }, weapon: undefined, defense: undefined, skilledLeaderSurvives: true }).bonus, 0);
}
const bureau = [{ skill: 'bureaucrat' as const, leader: 'b', faceUp: false, captured: false }];
assert.equal(bureaucratBattlePenalty(bureau, 'b', true, 3), 3);
assert.equal(bureaucratBattlePenalty(bureau, 'b', false, 3), 0);
assert.throws(() => bureaucratBattlePenalty(bureau, 'b', true, undefined));
const mentat = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'mentat' }));
assert.ok(mentat.log.some((l) => l.text.includes('gained 2 battle strength from Mentat')));
conserved(mentat);
const game = leaderSkillBattle({ skill: 'bureaucrat' });
game.players[1].forces['tueks_sietch:4'] = 1; game.players[1].reserves--;
const resolved = resolveLeaderSkillBattle(game);
assert.ok(resolved.log.some((l) => l.text.includes('battle total fell by 2')));
conserved(resolved);
const killed = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'bureaucrat', weaponKills: true }));
assert.ok(!killed.log.some((l) => l.text.includes('battle total fell')));
});
void test('Bureaucrat counts distinct occupation and excludes advisors and unoccupied virtual control', () => {
const player = { faction: 'beneGesserit' as const, forces: { 'arrakeen:10': 2, 'carthag:11': 1, 'tueks_sietch:4': 1 }, advisors: { carthag: { lockedTurn: 1 } } };
assert.equal(leaderSkillStrongholdCount({}, player), 2);
assert.equal(leaderSkillStrongholdCount({}, { ...player, forces: {} }), 0);
});
void test('Sandmaster adds board spice once after a surviving victory even when every dialed force dies', () => {
const game = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14, dial: 5 });
game.spice = { 'wind_pass:14': 2 };
const done = resolveLeaderSkillBattle(game);
assert.equal(done.spice['wind_pass:14'], 5);
assert.equal(done.players[0].spice, 20);
assert.equal(done.players[0].forces['wind_pass:14'] ?? 0, 0);
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(done))), done);
reject(done, 'd', { type: 'traitorCall', call: false });
for (const options of [{ hide: false }, { weaponKills: true }]) {
const other = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14, ...options });
other.spice = { 'wind_pass:14': 2 };
assert.equal(resolveLeaderSkillBattle(other).spice['wind_pass:14'], 2);
}
const empty = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'sandmaster' }));
assert.equal(empty.lastBattleContext?.sandmaster, undefined);
const ambiguous = leaderSkillBattle({ skill: 'sandmaster', territory: 'wind_pass', sector: 14 });
ambiguous.spice = { 'wind_pass:14': 2, 'wind_pass:15': 3 };
reject(ambiguous, 'd', { type: 'traitorCall', call: false });
});
void test('native Rihani has a compulsory private peek then commits its separate optional draw before seeing cards', () => {
const pending = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer' }));
const receipt = pending.rihaniHistory![0], beforeHand = [...pending.players[0].traitors];
assert.equal(receipt.peeked.length, 2); assert.equal(receipt.drawn.length, 0);
assert.equal(pending.decision?.kind, 'rihani');
const own = viewGame(pending, 'a'), other = viewGame(pending, 'd');
assert.equal(own.rihani!.history[0].peeked.length, 2);
assert.deepEqual(other.rihani!.history, []);
assert.equal('drawn' in other.rihani!.pending!, false);
assert.equal('rihaniHistory' in other, false);
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(pending))), pending);
reject(pending, 'd', { type: 'decision', event: receipt.event, draw: true });
reject(pending, 'a', { type: 'decision', event: 'stale', draw: true });
const decline = applyAction(pending, 'a', { type: 'decision', event: receipt.event, draw: false });
assert.deepEqual(decline.players[0].traitors, beforeHand);
assert.equal(decline.decision?.kind, 'battleCards');
const drawn = applyAction(pending, 'a', { type: 'decision', event: receipt.event, draw: true });
assert.equal(drawn.players[0].traitors.length, beforeHand.length + 2);
assert.equal(drawn.rihaniHistory![0].drawn.length, 2);
reject(drawn, 'a', { type: 'decision', event: receipt.event, draw: false });
const r = drawn.rihaniHistory![0];
reject(drawn, 'a', { type: 'decision', event: r.event, cards: [r.eligible[0], r.drawn[0]] });
const done = applyAction(JSON.parse(JSON.stringify(drawn)), 'a', { type: 'decision', event: r.event, cards: [r.drawn[0], r.eligible[0]] });
assert.equal(done.players[0].traitors.length, beforeHand.length);
assert.ok(done.players[0].traitors.includes(r.drawn[0]));
assert.ok(done.traitorReserve!.includes(r.eligible[0]));
assert.ok(done.traitorReserve!.includes(r.drawn[1]));
assert.ok(!done.players[0].revealedTraitors?.includes(r.eligible[0]), 'exchange disclosure is not a traitor call');
assert.equal(done.lastBattleContext?.rihani?.completed, true);
reject(done, 'a', { type: 'decision', event: r.event, cards: [r.drawn[0], r.eligible[0]] });
for (const complete of [decline, done]) {
const bad = structuredClone(complete); bad.traitorReserve!.reverse();
assert.throws(() => viewGame(bad, 'a'));
assert.throws(() => normalizeAutomaticGame(bad));
reject(bad, 'a', { type: 'decision', cards: [] });
}
for (const state of [pending, decline, drawn, done]) conserved(state);
});
void test('Rihani normal, bluff, killed, used-traitor and captured bands keep their distinct eligibility', () => {
const trainer = [{ skill: 'rihani-decipherer' as const, leader: 'r', faceUp: true, captured: false }];
assert.equal(rihaniVictorySkill(trainer, 'r', false), null);
assert.equal(rihaniVictorySkill(trainer, 'other', false)?.normal, true);
const normal = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', hide: false }));
assert.equal(normal.rihaniHistory![0].peeked.length, 2);
assert.equal(normal.rihaniHistory![0].stage, 'complete');
for (const options of [{ bluff: true }, { weaponKills: true }]) {
const noSkill = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', ...options }));
assert.equal(noSkill.rihaniHistory, undefined);
}
const used = leaderSkillBattle({ skill: 'rihani-decipherer' });
used.players[0].revealedTraitors = [...used.players[0].traitors];
const noExchange = resolveLeaderSkillBattle(used);
assert.equal(noExchange.rihaniHistory![0].stage, 'complete');
assert.equal(noExchange.rihaniHistory![0].peeked.length, 2);
const captive = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', captured: true }));
assert.equal(captive.rihaniHistory![0].peeked.length, 0);
assert.equal(captive.rihaniHistory![0].stage, 'offer');
assert.equal(captive.players[1].leaders[0].capturedBy, undefined);
conserved(captive);
});
void test('Rihani rejects changed physical custody, detached assignment or missing choice without mutation', () => {
const good = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer' }));
for (const corrupt of [
(g: Game) => { g.rihaniHistory = []; },
(g: Game) => { g.rihaniHistory![0].peeked.reverse(); },
(g: Game) => { g.traitorReserve!.reverse(); },
(g: Game) => { g.decision = null; },
(g: Game) => { g.lastBattleContext!.rihani = undefined; },
(g: Game) => { const a = g.leaderSkills!.assignments.find((a) => a.skill === 'rihani-decipherer')!; [a.skill, g.leaderSkills!.deck[0]] = [g.leaderSkills!.deck[0], a.skill]; },
]) {
const bad = structuredClone(good); corrupt(bad); const before = JSON.stringify(bad);
assert.throws(() => viewGame(bad, 'a')); assert.throws(() => normalizeAutomaticGame(bad));
reject(bad, 'a', { type: 'advanceBots' }); assert.equal(JSON.stringify(bad), before);
}
});
void test('all four Rihani bots draw and complete the owned exchange through saved public/private views', () => {
const pending = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer' }));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, 'a'); view.players[0].bot = difficulty;
const drawn = applyAction(pending, 'a', botActions(view)[0]);
const returnView = viewGame(JSON.parse(JSON.stringify(drawn)), 'a'); returnView.players[0].bot = difficulty;
const done = applyAction(drawn, 'a', botActions(returnView)[0]);
assert.equal(done.rihaniHistory![0].stage, 'complete', difficulty); conserved(done);
}
});
void test('native Suk casualty rescue completes before a returned captive grants its frozen Rihani exchange', () => {
const done = resolveLeaderSkillBattle(leaderSkillBattle({ skill: 'rihani-decipherer', captured: true, capturedNativeSkill: 'suk-graduate', hide: false }));
assert.equal(done.players[0].reserves, 16);
assert.equal(done.players[0].tanks, 3);
assert.equal(done.decision?.kind, 'rihani');
assert.equal(done.rihaniHistory![0].peeked.length, 0);
assert.equal(done.players[1].leaders[0].capturedBy, undefined);
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(done))), done);
conserved(done);
});
void test('a Rihani traitor victory inspects normally but cannot exchange the card just used to win', () => {
let game = leaderSkillBattle({ skill: 'rihani-decipherer' });
const target = game.battle!.plans.d.leader!, old = game.players[0].traitors[0];
if (target !== old) {
const source = [game.traitorReserve!, game.players[1].traitors].find((cards) => cards.includes(target))!;
source[source.indexOf(target)] = old; game.players[0].traitors[0] = target;
}
delete game.battle!.traitorCalls.a;
game = applyAction(game, 'a', { type: 'traitorCall', call: true });
const done = resolveLeaderSkillBattle(game);
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.deepEqual(done.players[0].revealedTraitors, [target]);
assert.equal(done.rihaniHistory![0].peeked.length, 2);
assert.deepEqual(done.rihaniHistory![0].eligible, []);
assert.equal(done.rihaniHistory![0].stage, 'complete');
conserved(done);
});
+101
View File
@@ -0,0 +1,101 @@
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, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
function dealtSkillGame(native: 'emperor' | 'atreides' | 'harkonnen', advanced: boolean, skill: LeaderSkillId = 'suk-graduate'): 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 === skill);
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], skill);
return game;
}
export function advancedAtreidesSukOffer(): Game {
return dealtSkillGame('atreides', true);
}
/** Genuine module setup, then a conserved, explicitly staged battle position. */
export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor';
let game = dealtSkillGame(native, advanced, skill);
for (const id of ['a', 'd']) {
const offer = game.leaderSkills!.offers[id];
const selected = id === 'a' && advanced && atreides && skill === 'suk-graduate' ? offer.cards[1] : offer.cards[0];
game = applyAction(game, id, { type: 'leaderSkill', event: offer.event,
skill: selected, leader: id === 'a' ? `${native}-0` : 'guild-0' });
}
if (advanced && atreides && skill === 'suk-graduate') {
// 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';
if (capturedNativeSkill && own.skill !== capturedNativeSkill) {
const index = game.leaderSkills!.deck.indexOf(capturedNativeSkill);
assert.ok(index >= 0);
[own.skill, game.leaderSkills!.deck[index]] = [game.leaderSkills!.deck[index], own.skill];
}
}
const battleKey = `${territory}:${sector}`;
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.forces = { [battleKey]: 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: { [battleKey]: 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, 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 resolveLeaderSkillBattle(game: Game): Game {
return applyAction(game, 'd', { type: 'traitorCall', call: false });
}
+17
View File
@@ -187,6 +187,23 @@ function play(
return card;
}
void test('Mentat and Bureaucrat change the winner at the score boundary without changing printed strength', () => {
const mentat = battle();
assert.equal(quoteBattleResolution(mentat).winner, 'a');
mentat.defender.leaderSkills = [{ skill: 'mentat', leader: mentat.defender.leader!.id, faceUp: false, captured: false }];
const boosted = quoteBattleResolution(mentat);
assert.equal(boosted.winner, 'd');
assert.deepEqual(boosted.scores, { attacker: 5, defender: 6 });
assert.deepEqual(boosted.leaderStrengths, { attacker: 3, defender: 2 });
const bureau = battle();
bureau.defender.leaderSkills = [{ skill: 'bureaucrat', leader: bureau.defender.leader!.id, faceUp: false, captured: false }];
bureau.attacker.occupiedStrongholds = 2;
const penalized = quoteBattleResolution(bureau);
assert.equal(penalized.winner, 'd');
assert.deepEqual(penalized.scores, { attacker: 3, defender: 4 });
assert.deepEqual(penalized.leaderStrengths, { attacker: 3, defender: 2 });
});
void test('battle quote adds only surviving skill receipts to scores while preserving printed strength and bounty', () => {
const input = battle();
const worthless = play(
+6 -2
View File
@@ -66,7 +66,11 @@ void test('renders nothing when the projected module is absent and never invents
publicOnly,
/Your private offer|Choose one private skill/,
);
assert.doesNotMatch(publicOnly, /Bureaucrat|Spice Banker|Diplomat/);
assert.deepEqual(
[...publicOnly.matchAll(/aria-label="([^"]+) Leader Skill card"/g)].map((match) => match[1]),
['Warmaster'],
'General guidance may name skills; only the public assignment gets a card face.',
);
});
void test('every public assignment card exposes its complete canonical normal and battle text', () => {
@@ -160,7 +164,7 @@ void test('revival asks before preview and does not offer a decline after cards
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.doesNotMatch(undrawn, /Leader Skill card"|Your private offer/);
assert.equal(
disabledControls(
markup(
+11
View File
@@ -9,6 +9,7 @@ import {
} from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { botActions } from '../game/bots';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
@@ -409,6 +410,16 @@ void test('battle death and Ghola revival preserve one private replacement skill
1,
);
assertSkillCustody(game);
// The randomly assigned winning skill can require casualty or Traitor choices
// before card cleanup. Resolve those real choices before testing Ghola revival.
for (let step = 0; step < 3 && ['rihani', 'sukRescue'].includes(game.decision?.kind ?? ''); step++) {
const owner = f.auths.findIndex((auth) => auth.playerId === game.decision!.player);
assert.ok(owner >= 0);
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
const [choice] = botActions(seat);
assert.ok(choice, 'the winning skill must have a legal saved continuation');
game = await act(f, owner, choice);
}
if (game.decision?.kind === 'battleCards')
game = await act(
f,
+2 -95
View File
@@ -1,95 +1,2 @@
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 });
}
/** Keep the earlier Suk fixture API while sharing genuine setup with later skills. */
export { leaderSkillBattle as sukBattle, resolveLeaderSkillBattle as resolveSuk, advancedAtreidesSukOffer } from './leader-skill-battle-fixture';