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:
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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user