Implement Emperor Nexus Cunning with physical casualty recovery

This commit is contained in:
Quintine committed 2026-09-10 11:42:01 +10:00
1 parent b05125de9a
commit ba0b5647e1
27 files changed
+2487 -64

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
nexusTraitorFixture,
nexusTraitorInventory,
} from './fixture-nexus-traitors';
import { nexusAllow } from './fixture-nexus-cards';
export function nexusSardaukarFixture(
options: {
seatIds?: [string, string, string];
advanced?: boolean;
opponentFaction?: 'guild' | 'atreides' | 'fremen';
normal?: number;
starred?: number;
} = {},
): Game {
let g = nexusTraitorFixture({
ownerFaction: 'emperor',
opponentFaction:
options.opponentFaction === 'fremen'
? undefined
: options.opponentFaction,
advanced: options.advanced ?? true,
seatIds: options.seatIds,
phase: 6,
});
const owner = g.players[0],
opponent = g.players[options.opponentFaction === 'fremen' ? 2 : 1];
const normal = options.normal ?? 7,
starred = options.starred ?? 0;
assert.ok(normal >= 0 && normal <= 12 && starred >= 0 && starred <= 5);
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('emperor');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner.id]!;
cards.hands[owner.id] = 'emperor';
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
if (p.elites) {
p.elites.forces = {};
p.elites.tanks = 0;
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
}
}
owner.forces = { 'pasty_mesa:5': normal + starred, 'hagga_basin:12': 3 };
owner.reserves = 17 - normal - starred;
if (owner.elites) {
owner.elites.reserves = 5 - starred;
if (starred) owner.elites.forces = { 'pasty_mesa:5': starred };
}
opponent.forces = { 'pasty_mesa:5': 2, 'hagga_basin:12': 2 };
opponent.reserves = 16;
Object.assign(g, {
active: owner.id,
order: g.players.map((p) => p.id),
ready: [],
response: null,
decision: null,
phaseOpening: null,
});
g = applyAction(g, owner.id, {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: opponent.id,
});
for (const id of [owner.id, opponent.id])
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
nexusSardaukarInventory(g);
return g;
}
export const nexusSardaukarPlayer = (g: Game) =>
g.players.find((p) => p.faction === 'emperor')!;
export function nexusSardaukarInventory(g: Game): void {
nexusTraitorInventory(g);
const emperor = nexusSardaukarPlayer(g);
if (!emperor.elites) {
assert.equal(g.advanced, false);
return;
}
assert.equal(
emperor.elites!.reserves +
emperor.elites!.tanks +
Object.values(emperor.elites!.forces).reduce((sum, n) => sum + n, 0),
5,
);
}
export function prepareSardaukarBattle(g: Game): Game {
while (g.battle!.preparation)
g = applyAction(g, g.battle!.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
export function beginNexusSardaukar(g: Game): Game {
const owner = nexusSardaukarPlayer(g);
const offer = viewGame(g, owner.id).nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
return applyAction(g, owner.id, {
type: 'nexusSardaukar',
event: offer.event,
});
}
export const allowNexusSardaukar = nexusAllow;
+1 -1
View File
@@ -123,7 +123,7 @@ void test('the owned face highlights its mode from the public roster and all thr
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
const view = fixture();
const html = markup(view, true);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning/);
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning/);
assert.match(html, /other effects remain unavailable/);
assert.doesNotMatch(html, /other card effects are not playable yet/);
assert.match(html, /10 in deck · 0 discarded · 2 held/);
+84
View File
@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { casualtyOptions } from '../game/combat';
import {
nexusSardaukarFixture,
nexusSardaukarInventory,
beginNexusSardaukar,
allowNexusSardaukar,
prepareSardaukarBattle,
} from './fixture-nexus-sardaukar';
void test('all profiles declare actual Cunning then seal a physically legal boosted plan after allowance', () => {
const initial = nexusSardaukarFixture();
for (const difficulty of DIFFICULTIES) {
const v = viewGame(initial, 'p');
v.players[0].bot = difficulty;
const before = structuredClone(v);
const declaration = botActions(v)[0];
assert.equal(declaration.type, 'nexusSardaukar');
assert.deepEqual(v, before);
const g = prepareSardaukarBattle(
allowNexusSardaukar(applyAction(initial, 'p', declaration)),
);
const boosted = viewGame(g, 'p');
boosted.players[0].bot = difficulty;
const plans = botActions(boosted);
assert.ok(plans.length);
for (const plan of plans) {
assert.equal(plan.type, 'battlePlan');
const losses = casualtyOptions(
boosted.battle!.ownForces!,
Number(plan.dial),
Number(plan.support),
);
assert.ok(losses.length);
assert.ok(losses.every((loss) => loss.elite === 0 && loss.normal <= 7));
}
const sealed = applyAction(g, 'p', plans[0]);
nexusSardaukarInventory(sealed);
assert.deepEqual(sealed.players[0].elites, initial.players[0].elites);
assert.equal(sealed.battle!.plans.p.dial, plans[0].dial);
}
});
void test('Emperor offer policy never reads private cards or an opposing sealed plan', () => {
const v = viewGame(nexusSardaukarFixture(), 'p');
v.players[0].bot = 'Brutal';
for (const p of v.players)
for (const field of ['hand', 'traitors', 'faceDancers', 'spice'])
Object.defineProperty(p, field, {
get() {
throw new Error(`Private ${field} read`);
},
});
Object.defineProperty(v.battle!, 'plans', {
get() {
throw new Error('Read sealed plan');
},
});
assert.equal(botActions(v)[0].type, 'nexusSardaukar');
});
void test('native enhancement uses ordinary Karama response policy before any boosted planning', () => {
const initial = nexusSardaukarFixture();
const index = initial.deck.findIndex((c) => c.effect === 'karama');
const card = initial.deck.splice(index, 1)[0];
initial.players[1].hand.push(card);
const g = beginNexusSardaukar(initial);
for (const difficulty of DIFFICULTIES) {
const v = viewGame(g, 'q');
v.players.find((p) => p.id === 'q')!.bot = difficulty;
const action = botActions(v)[0];
assert.deepEqual(
action,
difficulty === 'Easy' || difficulty === 'Medium'
? { type: 'passResponse' }
: { type: 'card', card: card.id, mode: 'cancel' },
);
assert.equal(v.nexusSardaukar!.active, false);
}
});
@@ -0,0 +1,281 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { unitStore } from './fixture-nexus-room-store';
import {
nexusSardaukarFixture,
nexusSardaukarInventory,
beginNexusSardaukar,
allowNexusSardaukar,
prepareSardaukarBattle,
} from './fixture-nexus-sardaukar';
const clock = { now: () => 10000, sleep: async () => {} };
const choice = (index: number) => ({ type: 'decision', choice: index });
function resolvedVictory(): Game {
let g = prepareSardaukarBattle(
allowNexusSardaukar(beginNexusSardaukar(nexusSardaukarFixture())),
);
const [owner, opponent] = g.players;
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
const opposing = [...opponent.leaders].sort(
(a, b) => a.strength - b.strength,
)[0];
g = applyAction(g, owner.id, {
type: 'battlePlan',
dial: 6,
support: 3,
leader: leader.id,
});
g = applyAction(g, opponent.id, {
type: 'battlePlan',
dial: 0,
leader: opposing.id,
});
for (const id of [owner.id, opponent.id])
g = applyAction(g, id, { type: 'traitorCall', call: false });
assert.equal(g.battle, null);
assert.equal(g.decision?.kind, 'battleLosses');
assert.deepEqual(
g.decision.options.map((loss) => [loss.normal, loss.elite]),
[
[3, 0],
[4, 0],
[5, 0],
],
);
nexusSardaukarInventory(g);
return g;
}
async function fixture() {
const store = unitStore();
const made = await store.rooms.createRoom(
'Sardaukar casualties',
'emperor',
true,
[],
);
const code = made.view.code;
const tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
// Bind disposable authentication seats to already-final fixture identities;
// no game identity or signed source history is rewritten after setup.
for (const [index, token] of tokens.entries()) {
const auth = await store.rooms.authenticate(code, token);
store.sqlite
.prepare(
'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?',
)
.run(['p', 'q', 'r'][index], code, auth.playerId);
}
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const initial = resolvedVictory();
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initial), initial.version, code);
store.writes.length = 0;
return { ...store, code, auths, initial };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
async function restored(f: Fixture, g: Game) {
const before = row(f);
assert.deepEqual(normalizeAutomaticGame(structuredClone(g)), g);
for (const auth of f.auths) {
const projected = await f.restart().readSeatView(f.code, auth);
assert.deepEqual(projected, viewGame(g, auth.playerId));
assert.doesNotMatch(
JSON.stringify(projected),
/nexusSardaukarHistory|nexusSardaukarCasualties/,
);
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusSardaukarInventory(g);
}
void test('SQLite restores each committed Emperor casualty choice before and after ordinary-counter settlement', async () => {
for (const [index, losses] of [3, 4, 5].entries()) {
const f = await fixture();
try {
await restored(f, f.initial);
await f
.restart()
.act(f.code, f.auths[0], f.initial.version, choice(index), clock);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].tanks, losses);
assert.equal(done.players[0].forces['pasty_mesa:5'], 7 - losses);
assert.equal(done.players[0].elites!.tanks, 0);
assert.equal(done.players[0].elites!.reserves, 5);
assert.equal(
done.nexusSardaukarHistory![0].casualties!.outcome,
'complete',
);
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], done.version, choice(index), clock),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
}
});
void test('SQLite competing different legal casualty allocations settle one physical group exactly once', async () => {
const f = await fixture();
try {
let arrived = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrived === 2) release();
await gate;
};
const attempts = await Promise.allSettled(
[0, 2].map((index) =>
f
.restart()
.act(f.code, f.auths[0], f.initial.version, choice(index), clock),
),
);
delete f.hooks.beforeWrite;
assert.equal(
attempts.filter((result) => result.status === 'fulfilled').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.initial.version + 1);
assert.ok([3, 5].includes(done.players[0].tanks));
await restored(f, done);
const before = row(f);
await assert.rejects(
f.restart().act(f.code, f.auths[0], f.initial.version, choice(0), clock),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite edited winner costs, effective roles or missing casualty proof reject every read and action without writes', async () => {
const f = await fixture();
try {
const edits: [string, (g: Game) => void][] = [
[
'available but illegal normal loss',
(g) => {
if (g.decision?.kind === 'battleLosses')
g.decision.options[0].normal = 6;
},
],
[
'support allocation',
(g) => {
if (g.decision?.kind === 'battleLosses')
g.decision.options[0].paidNormal = 0;
},
],
[
'effective temporary role',
(g) => {
delete g.nexusSardaukarHistory![0].casualties!.forces.temporaryElite;
},
],
[
'forged completion',
(g) => {
g.nexusSardaukarHistory![0].casualties!.outcome = 'complete';
},
],
[
'missing choice',
(g) => {
g.decision = null;
},
],
[
'missing casualty receipt',
(g) => {
delete g.nexusSardaukarHistory![0].casualties;
},
],
[
'missing independent battle marker',
(g) => {
delete g.lastBattleContext!.nexusSardaukarCasualties;
},
],
[
'missing history',
(g) => {
delete g.nexusSardaukarHistory;
},
],
[
'missing history and latest outcome',
(g) => {
delete g.nexusSardaukarHistory;
delete g.nexusSardaukarLast;
},
],
];
for (const [label, edit] of edits) {
const bad = structuredClone(f.initial);
edit(bad);
const snapshot = JSON.stringify(bad);
for (const auth of f.auths)
assert.throws(() => viewGame(bad, auth.playerId), /./, label);
assert.throws(() => normalizeAutomaticGame(bad), /./, label);
assert.throws(() => applyAction(bad, 'p', choice(0)), /./, label);
assert.equal(JSON.stringify(bad), snapshot, label);
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(snapshot, f.code);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(
f.restart().readSeatView(f.code, auth),
/./,
label,
);
await assert.rejects(
f.restart().act(f.code, f.auths[0], bad.version, choice(0), clock),
/./,
label,
);
await f
.restart()
.continueRoomAutomatic(f.code, clock)
.catch(() => {});
assert.deepEqual(row(f), before, label);
assert.equal(f.writes.length, 0, label);
}
} finally {
f.sqlite.close();
}
});
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
casualtyOptions,
maxCombatDial,
maxCombatSupport,
validCombatForces,
type CombatForces,
} from '../game/combat';
import {
stoneBurnerComparison,
stoneBurnerPlanBlock,
} from '../game/stone-burner';
import {
quoteBattleResolution,
type BattleResolutionInput,
} from '../game/battle-resolution-quote';
import { leaders } from '../game/cards';
const pool = (patch: Partial<CombatForces> = {}): CombatForces => ({
normal: 7,
elite: 0,
temporaryElite: 5,
eliteStrength: 2,
freeSupport: false,
...patch,
});
void test('temporary Sardaukar produce alternative ordinary casualties and pay only for physical counters', () => {
const forces = pool(),
before = structuredClone(forces);
assert.deepEqual(casualtyOptions(forces, 2, 0), [
{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 },
{ normal: 3, elite: 0, paidNormal: 0, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(forces, 2, 1), [
{ normal: 1, elite: 0, paidNormal: 1, paidElite: 0 },
{ normal: 2, elite: 0, paidNormal: 1, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(forces, 12, 7), [
{ normal: 7, elite: 0, paidNormal: 7, paidElite: 0 },
]);
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), 7);
assert.deepEqual(forces, before);
});
void test('Salusa support, ordinary free support and canceled Sardaukar strength retain distinct role arithmetic', () => {
const salusa = pool({ eliteFreeSupport: true });
assert.equal(maxCombatSupport(salusa), 2);
assert.equal(maxCombatDial(salusa), 12);
assert.deepEqual(casualtyOptions(salusa, 12, 2), [
{ normal: 7, elite: 0, paidNormal: 2, paidElite: 0 },
]);
assert.deepEqual(casualtyOptions(salusa, 12, 7), []);
const normalFree = pool({ normalFreeSupport: true });
assert.equal(maxCombatSupport(normalFree), 5);
assert.deepEqual(casualtyOptions(normalFree, 12, 5), [
{ normal: 7, elite: 0, paidNormal: 5, paidElite: 0 },
]);
const suppressed = pool({ eliteStrength: 1 });
assert.equal(maxCombatDial(suppressed), 7);
for (let half = 0; half <= 14; half++)
for (let support = 0; support <= 7; support++) {
assert.deepEqual(
casualtyOptions(suppressed, half / 2, support),
casualtyOptions(
{ ...suppressed, temporaryElite: undefined },
half / 2,
support,
),
);
}
assert.equal(maxCombatSupport(pool({ freeSupport: true })), 0);
assert.deepEqual(casualtyOptions(pool({ freeSupport: true }), 12, 0), [
{ normal: 7, elite: 0, paidNormal: 0, paidElite: 0 },
]);
});
/** Independent labeled-counter choices: survive, die unsupported, die supported. */
function assignments(forces: CombatForces) {
let states = new Set(['0:0:0']);
for (let counter = 0; counter < forces.normal; counter++) {
const temporary = counter < (forces.temporaryElite ?? 0);
const strength = temporary ? forces.eliteStrength : 1;
const free =
forces.freeSupport ||
(temporary ? forces.eliteFreeSupport : forces.normalFreeSupport);
const fixedHalf =
!temporary && forces.normalFixedHalf && !forces.normalFreeSupport;
const choices = [
[0, 0, 0],
[1, strength * (free && !fixedHalf ? 2 : 1), 0],
];
if (!free && !fixedHalf) choices.push([1, strength * 2, 1]);
const next = new Set<string>();
for (const previous of states) {
const [loss, half, paid] = previous.split(':').map(Number);
for (const [n, h, p] of choices)
next.add(`${loss + n}:${half + h}:${paid + p}`);
}
states = next;
}
return [...states].map((state) => state.split(':').map(Number));
}
void test('mapped losses match independent counter enumeration across mixed roles and every support exemption', () => {
for (const normal of [0, 1, 2, 4, 6])
for (let temporaryElite = 0; temporaryElite <= normal; temporaryElite++)
for (const eliteStrength of [1, 2] as const)
for (const flags of [
{},
{ eliteFreeSupport: true },
{ normalFreeSupport: true },
{ normalFixedHalf: true },
{ normalFixedHalf: true, normalFreeSupport: true },
{ freeSupport: true },
{ freeSupport: true, normalFixedHalf: true },
{ eliteFreeSupport: true, normalFreeSupport: true },
]) {
const forces = pool({
normal,
temporaryElite,
eliteStrength,
...flags,
});
const expected = assignments(forces);
assert.equal(
maxCombatDial(forces),
Math.max(...expected.map(([, half]) => half)) / 2,
);
assert.equal(
maxCombatSupport(forces),
Math.max(...expected.map(([, , paid]) => paid)),
);
for (let half = 0; half <= normal * 4; half++)
for (let support = 0; support <= normal; support++) {
const losses = expected
.filter(([, h, p]) => h === half && p === support)
.map(([n]) => n)
.sort((a, b) => a - b);
assert.deepEqual(
casualtyOptions(forces, half / 2, support),
losses.map((normal) => ({
normal,
elite: 0,
paidNormal: support,
paidElite: 0,
})),
JSON.stringify([forces, half, support]),
);
}
}
});
void test('Stone Burner compares physical survivors and separates temporary-role cache entries', () => {
const own: CombatForces = {
normal: 4,
elite: 0,
eliteStrength: 1,
freeSupport: true,
};
const temporary = pool({ normal: 3, temporaryElite: 2 });
const ordinary = { ...temporary, temporaryElite: 0 };
assert.deepEqual(stoneBurnerComparison(temporary, 2, 0, own, 2, 0), {
winner: 'defender',
attacker: [1],
defender: [2],
});
assert.deepEqual(stoneBurnerComparison(temporary, 2, 1, own, 2, 0), {
winner: null,
attacker: [1, 2],
defender: [2],
});
for (let repeat = 0; repeat < 3; repeat++) {
assert.equal(stoneBurnerPlanBlock(own, 3, 0, ordinary, 'attacker'), null);
assert.match(
stoneBurnerPlanBlock(own, 3, 0, temporary, 'attacker')!,
/different legal force allocations/,
);
}
});
function resolution(): BattleResolutionInput {
const a = leaders('emperor')[0],
d = leaders('guild')[0];
return {
advanced: true,
turn: 2,
territory: 'pasty_mesa',
attacker: {
id: 'e',
faction: 'emperor',
spice: 7,
hand: [],
leader: a,
forces: pool(),
plan: { dial: 12, support: 7, leader: a.id, weapon: null, defense: null },
},
defender: {
id: 'g',
faction: 'guild',
spice: 0,
hand: [],
leader: d,
forces: { normal: 1, elite: 0, eliteStrength: 1, freeSupport: false },
plan: { dial: 0, support: 0, leader: d.id, weapon: null, defense: null },
},
voters: [
{ id: 'e', beneficiary: 'e', called: false, traitors: [] },
{ id: 'g', beneficiary: 'g', called: false, traitors: [] },
],
participants: [
{ id: 'e', faction: 'emperor' },
{ id: 'g', faction: 'guild' },
],
physicalCards: [],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
};
}
void test('battle quote retains the physical pool, ordinary casualties and exact support payment', () => {
const input = resolution(),
before = structuredClone(input),
quote = quoteBattleResolution(input);
assert.equal(quote.winner, 'e');
assert.equal(quote.payments.find((p) => p.player === 'e')!.cost, 7);
assert.deepEqual(quote.casualties!.forces, input.attacker.forces);
assert.deepEqual(quote.casualties!.options, [
{ normal: 7, elite: 0, paidNormal: 7, paidElite: 0 },
]);
assert.deepEqual(input, before);
});
void test('invalid temporary populations reject before calculation without changing physical custody', () => {
for (const temporaryElite of [-1, 0.5, 8, 21, NaN, Infinity, '5', null]) {
const forces = { ...pool(), temporaryElite } as unknown as CombatForces;
assert.equal(validCombatForces(forces), false);
assert.deepEqual(casualtyOptions(forces, 2, 0), []);
assert.equal(maxCombatDial(forces), 0);
assert.equal(maxCombatSupport(forces), 0);
assert.match(
stoneBurnerPlanBlock(forces, 2, 0, pool(), 'attacker')!,
/valid supported physical/,
);
const input = resolution();
input.attacker.forces = forces;
const before = structuredClone(input);
assert.throws(() => quoteBattleResolution(input), /valid force/);
assert.deepEqual(input, before);
}
assert.equal(validCombatForces(pool({ elite: 1 })), false);
assert.equal(validCombatForces(pool({ normal: 21 })), false);
assert.equal(
validCombatForces(pool({ normal: 20, temporaryElite: 20 })),
true,
);
assert.equal(maxCombatDial(pool({ normal: 20, temporaryElite: 20 })), 40);
});
+205
View File
@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { BattlePromises } from '../components/battle-promises';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
import { NexusSardaukar } from '../components/nexus-sardaukar';
import { nexusSardaukarAction } from '../game/nexus-sardaukar-options';
import {
nexusSardaukarFixture,
beginNexusSardaukar,
allowNexusSardaukar,
prepareSardaukarBattle,
} from './fixture-nexus-sardaukar';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/image'
? 'vinext/shims/image'
: specifier === 'next/link'
? 'vinext/shims/link'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => [m[1], m[1]]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const html = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(NexusSardaukar, { game, busy, act() {} }));
void test('Emperor Cunning offers one event-bound five-counter play with explicit physical identity', () => {
const v = viewGame(nexusSardaukarFixture(), 'p');
const offer = v.nexusSardaukar!.offer!;
assert.deepEqual(nexusSardaukarAction(v, offer.event), {
type: 'nexusSardaukar',
event: offer.event,
});
assert.match(html(v), /Use five forces as Sardaukar/);
assert.match(html(v), /ordinary physical identity, including when lost/);
assert.doesNotMatch(html(v), /disabled=""|checkbox/);
assert.match(html(v, true), /disabled=""/);
assert.equal(nexusSardaukarAction(v, 'stale'), null);
offer.blocked = 'An earlier Battle Plan commitment would become impossible.';
assert.equal(nexusSardaukarAction(v, offer.event), null);
assert.match(html(v), /earlier Battle Plan commitment/);
});
void test('Emperor control retains actor, Advanced, unsealed plan and interruption fences', () => {
const initial = viewGame(nexusSardaukarFixture(), 'p');
for (const mutate of [
(v: GameView) => {
v.me = 'q';
},
(v: GameView) => {
v.advanced = false;
},
(v: GameView) => {
v.phase = 5;
},
(v: GameView) => {
v.nexusCards!.card = null;
},
(v: GameView) => {
v.battle!.submitted = ['p'];
},
(v: GameView) => {
v.decision = { kind: 'nullentropy', player: 'p' };
},
(v: GameView) => {
v.truthtrance = {
stage: 'priority',
queue: [],
passed: [],
question: null,
};
},
(v: GameView) => {
v.automaticContinuationPending = true;
},
(v: GameView) => {
v.nexusCards!.waiting = ['q'];
},
]) {
const v = structuredClone(initial);
mutate(v);
assert.equal(nexusSardaukarAction(v, v.nexusSardaukar!.offer!.event), null);
}
for (const options of [{ normal: 4 }, { starred: 1 }]) {
const v = viewGame(nexusSardaukarFixture(options), 'p');
const offer = v.nexusSardaukar!.offer!;
assert.ok(offer.blocked);
assert.equal(nexusSardaukarAction(v, offer.event), null);
assert.ok(html(v).includes(offer.blocked));
}
});
void test('public pending and active notices avoid private hands and identify ordinary-counter losses', () => {
const initial = nexusSardaukarFixture();
const index = initial.deck.findIndex((c) => c.effect === 'karama');
initial.players[1].hand.push(initial.deck.splice(index, 1)[0]);
const g = beginNexusSardaukar(initial);
assert.equal(g.response?.kind, 'nexusSardaukar');
for (const id of ['p', 'q', 'r']) {
const v = viewGame(g, id);
assert.equal(v.nexusSardaukar!.active, false);
assert.equal(v.battle!.ownForces?.temporaryElite, undefined);
assert.equal(v.battle!.opponentForces?.temporaryElite, undefined);
Object.defineProperty(v.nexusSardaukar!, 'offer', {
get() {
throw new Error('Private offer read');
},
});
for (const p of v.players)
Object.defineProperty(p, 'hand', {
get() {
throw new Error('Private hand read');
},
});
assert.match(html(v), /Karama may cancel the entire enhancement/);
}
const allowed = allowNexusSardaukar(g);
const observer = viewGame(allowed, 'q');
assert.equal(observer.battle!.opponentForces?.temporaryElite, 5);
assert.match(html(observer), /Five ordinary Emperor counters/);
assert.match(html(observer), /ordinary Tanks/);
assert.doesNotMatch(html(observer), /<button/);
});
void test('actual battle editor uses temporary strength twelve and physical support seven without starred losses', () => {
const g = prepareSardaukarBattle(
allowNexusSardaukar(beginNexusSardaukar(nexusSardaukarFixture())),
);
const v = viewGame(g, 'p');
assert.equal(v.battle!.ownForces!.normal, 7);
assert.equal(v.battle!.ownForces!.elite, 0);
assert.equal(v.battle!.ownForces!.temporaryElite, 5);
const text = renderToStaticMarkup(
createElement(GameTable, {
game: v,
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(text, /max="12"/);
assert.match(text.match(/<input[^>]*id="battle-spice"[^>]*>/)![0], /max="7"/);
assert.match(
text,
/Their losses are ordinary counters, not starred Sardaukar/,
);
});
void test('real future Prescience preparation names Emperor Cunning without spending it during the search', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const cardIndex = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(cardIndex >= 0);
g.players[1].hand.push(g.deck.splice(cardIndex, 1)[0]);
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
g = prepareSardaukarBattle(nexusReload(g));
const before = JSON.stringify(g);
const v = viewGame(g, 'p');
const action = v.battle!.compliantPreparation!.actions[0];
assert.equal(action.type, 'nexusSardaukar');
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
const text = renderToStaticMarkup(
createElement(BattlePromises, {
game: v,
fill() {},
act() {},
busy: false,
}),
);
assert.match(
text,
/Declare Emperor Cunning to count five ordinary forces as Sardaukar for this battle/,
);
assert.doesNotMatch(text, /Ghola|NaN/);
assert.equal(JSON.stringify(g), before);
for (const id of ['q', 'r'])
assert.equal(viewGame(g, id).battle!.compliantPreparation, null);
g = applyAction(g, 'p', action);
assert.equal(g.response?.kind, 'nexusSardaukar');
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
g = allowNexusSardaukar(g);
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, true);
assert.equal(g.battle!.prescience!.value, 10);
});
+415
View File
@@ -0,0 +1,415 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Game,
} from '../game/engine';
import { casualtyOptions, maxCombatDial } from '../game/combat';
import {
createNexusSardaukar,
validateNexusSardaukar,
} from '../game/nexus-sardaukar';
import {
nexusSardaukarFixture,
nexusSardaukarPlayer,
nexusSardaukarInventory,
prepareSardaukarBattle,
beginNexusSardaukar,
allowNexusSardaukar,
} from './fixture-nexus-sardaukar';
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
import { drawNexusCard } from '../game/nexus-cards';
function hold(g: Game, id: string, effect: string): string {
const index = g.deck.findIndex(
(card) => card.effect === effect || card.kind === effect,
);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card.id;
}
function activate(g: Game): Game {
return allowNexusSardaukar(beginNexusSardaukar(g));
}
function request(g: Game) {
const p = nexusSardaukarPlayer(g);
return {
type: 'nexusSardaukar',
event: viewGame(g, p.id).nexusSardaukar!.offer!.event,
};
}
function resolveOrdinary(state: Game, dial = 6, support = 3): Game {
let g = prepareSardaukarBattle(state);
const owner = nexusSardaukarPlayer(g),
target = g.players.find((p) => p.id === g.battle!.defender)!;
const leader = [...owner.leaders].sort((a, b) => b.strength - a.strength)[0];
const opposing = [...target.leaders].sort(
(a, b) => a.strength - b.strength,
)[0];
g = applyAction(g, owner.id, {
type: 'battlePlan',
dial,
support,
leader: leader.id,
});
g = applyAction(g, target.id, {
type: 'battlePlan',
dial: 0,
leader: opposing.id,
});
for (const id of [owner.id, target.id])
g = applyAction(g, id, { type: 'traitorCall', call: false });
return g;
}
void test('Sardaukar receipt binds the original five-counter battle role without depending on later force custody', () => {
const g = nexusSardaukarFixture();
const receipt = createNexusSardaukar(
g,
'p',
g.battle!.event!,
'pasty_mesa',
7,
);
assert.equal(receipt.elite, 0);
assert.equal(receipt.count, 5);
validateNexusSardaukar({ turn: g.turn + 1, players: g.players }, receipt);
for (const patch of [
{ normal: 4 },
{ normal: 8 },
{ count: 4 },
{ elite: 1 },
{ owner: 'q' },
{ territory: 'hagga_basin' },
{ battle: 'other' },
{ version: 0 },
]) {
const bad = { ...receipt, ...patch };
assert.throws(() => validateNexusSardaukar(g, bad as typeof receipt));
}
assert.throws(() => createNexusSardaukar(g, 'q', 'battle', 'pasty_mesa', 7));
assert.throws(() => createNexusSardaukar(g, 'p', 'battle', 'pasty_mesa', 4));
const changed = g.players.map((p) => ({ id: p.id, faction: p.faction }));
changed[1].id = 'another-seat';
assert.throws(() =>
validateNexusSardaukar({ turn: g.turn, players: changed }, receipt),
);
});
void test('five temporary Sardaukar enhance an owned battle while every actual force and starred counter stays in place', () => {
let g = nexusSardaukarFixture();
hold(g, 'q', 'karama');
const before = structuredClone(g.players);
g = beginNexusSardaukar(g);
assert.equal(g.response?.kind, 'nexusSardaukar');
g = allowNexusSardaukar(g);
assert.deepEqual(g.players, before);
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.normal, 7);
assert.equal(forces.elite, 0);
assert.equal(forces.temporaryElite, 5);
assert.equal(maxCombatDial(forces), 12);
assert.deepEqual(
casualtyOptions(forces, 6, 3).map((x) => [x.normal, x.elite]),
[
[3, 0],
[4, 0],
[5, 0],
],
);
for (const p of g.players)
assert.equal(viewGame(g, p.id).nexusSardaukar!.active, true);
nexusSardaukarInventory(g);
});
void test('temporary Sardaukar retain the ordinary Fremen exception and need support for full strength', () => {
const g = activate(nexusSardaukarFixture({ opponentFaction: 'fremen' }));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite, 5);
assert.equal(forces.eliteStrength, 1);
assert.equal(forces.freeSupport, false);
assert.equal(maxCombatDial(forces), 7);
assert.deepEqual(
casualtyOptions(forces, 5, 5).map((x) => [x.normal, x.elite]),
[[5, 0]],
);
assert.deepEqual(casualtyOptions(forces, 5, 0), []);
});
void test('Karama stops the new role grant entirely and leaves all physical forces normal', () => {
let g = nexusSardaukarFixture();
const karama = hold(g, 'q', 'karama');
const original = structuredClone(nexusSardaukarPlayer(g).elites);
g = beginNexusSardaukar(g);
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
g = allowNexusSardaukar(g);
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite ?? 0, 0);
assert.equal(maxCombatDial(forces), 7);
assert.deepEqual(nexusSardaukarPlayer(g).elites, original);
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
assert.equal(
g.nexusCards!.cards!.discard.filter((c) => c === 'emperor').length,
1,
);
nexusSardaukarInventory(g);
});
void test('every winning casualty choice removes ordinary counters and never invents Sardaukar in the Tanks', () => {
const initial = activate(nexusSardaukarFixture());
for (const [index, losses] of [3, 4, 5].entries()) {
let g = resolveOrdinary(nexusReload(initial));
assert.equal(g.decision?.kind, 'battleLosses');
g = applyAction(g, 'p', { type: 'decision', choice: index });
const p = nexusSardaukarPlayer(g);
assert.equal(p.tanks, losses);
assert.equal(p.forces['pasty_mesa:5'], 7 - losses);
assert.equal(p.elites!.tanks, 0);
assert.equal(p.elites!.reserves, 5);
assert.deepEqual(p.elites!.forces, {});
nexusSardaukarInventory(g);
}
});
void test('the completed grant expires before a second battle in the same turn', () => {
let g = resolveOrdinary(activate(nexusSardaukarFixture()), 0, 0);
assert.equal(g.battle, null);
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'hagga_basin',
target: 'q',
});
assert.equal(viewGame(g, 'p').battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(viewGame(g, 'p').nexusSardaukar!.active, false);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
});
void test('Basic, short groups, actual Sardaukar, foreign actors and stale or repeated declarations reject unchanged', () => {
for (const options of [{ advanced: false }, { normal: 4 }, { starred: 1 }]) {
const g = nexusSardaukarFixture(options);
assert.ok(viewGame(g, 'p').nexusSardaukar!.offer!.blocked);
nexusReject(g, 'p', request(g), /Basic|Advanced|five|5|Sardaukar|ruling/i);
}
const g = nexusSardaukarFixture(),
action = request(g);
nexusReject(g, 'q', action, /Sardaukar|Cunning/);
nexusReject(g, 'p', { ...action, event: 'old' }, /Sardaukar|Cunning/);
nexusReject(g, 'p', { ...action, count: 4 }, /Sardaukar|Cunning/);
nexusReject(activate(g), 'p', action, /Sardaukar|Cunning/);
const ready = prepareSardaukarBattle(g);
const sealed = applyAction(ready, 'p', {
type: 'battlePlan',
dial: 0,
leader: ready.players[0].leaders[0].id,
});
nexusReject(sealed, 'p', action, /submitted|Sardaukar|Cunning/);
});
void test('saved declaration restores its public pending effect and only settles once', () => {
let g = nexusSardaukarFixture({
seatIds: ['emperor-seat', 'guild-seat', 'fremen-seat'],
});
hold(g, 'guild-seat', 'karama');
const action = request(g);
g = beginNexusSardaukar(g);
const saved = nexusReload(g);
for (const p of saved.players) {
const view = viewGame(saved, p.id);
assert.equal(view.nexusSardaukar!.pending!.owner, 'emperor-seat');
for (const other of view.players)
if (other.id !== p.id) assert.equal(other.hand, undefined);
}
assert.deepEqual(normalizeAutomaticGame(saved), saved);
g = allowNexusSardaukar(nexusReload(saved));
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
nexusReject(g, 'emperor-seat', action, /Sardaukar|Cunning/);
nexusSardaukarInventory(g);
});
void test('native Prescience accepts a future Cunning dial and keeps its answer binding after activation', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
assert.equal(g.nexusCards!.cards!.hands.p, 'emperor');
g = activate(prepareSardaukarBattle(g));
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 10,
support: 5,
leader: g.players[0].leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 10);
assert.equal(g.battle!.prescience!.value, 10);
});
void test('canceling Cunning reopens an impossible native dial answer without granting a new inspection', () => {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const karama = hold(g, 'q', 'karama');
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
g = nexusAllow(g);
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 10 });
const battle = g.battle!.event;
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.battle!.event, battle);
assert.equal(g.battle!.prescience!.player, 'q');
assert.equal(g.battle!.prescience!.field, 'dial');
assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer');
g = applyAction(nexusReload(g), 'p', { type: 'prescienceAnswer', value: 5 });
assert.equal(g.battle!.prescience!.value, 5);
g = prepareSardaukarBattle(g);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders[0].id,
});
assert.equal(g.battle!.plans.p.dial, 5);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
1,
);
assert.equal(g.discard.filter((card) => card.id === karama).length, 1);
nexusSardaukarInventory(g);
});
void test('canceled Sardaukar reopen the affected Cunning inspection while preserving the other answered element', () => {
for (const [nativeDial, extraLeader] of [
[true, false],
[false, false],
[true, true],
]) {
let g = nexusSardaukarFixture({ opponentFaction: 'atreides' });
const leader = g.players[0].leaders[0].id;
const unchangedField = extraLeader
? 'leader'
: nativeDial
? 'defense'
: 'weapon';
const unchangedValue = extraLeader ? leader : null;
const karama = hold(g, 'q', 'karama');
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
g = applyAction(g, 'q', {
type: 'prescience',
field: nativeDial ? 'dial' : 'weapon',
});
g = nexusAllow(g);
g = applyAction(g, 'p', {
type: 'prescienceAnswer',
value: nativeDial ? 10 : null,
});
g = prepareSardaukarBattle(g);
g = applyAction(g, 'q', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'cunning',
field: extraLeader ? 'leader' : nativeDial ? 'defense' : 'dial',
});
g = nexusAllow(g);
g = applyAction(g, 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: nativeDial ? unchangedValue : 10,
});
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(
g.battle!.preparation?.kind,
nativeDial ? 'prescienceAnswer' : 'nexusPrescienceAnswer',
);
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
g = applyAction(
nexusReload(g),
'p',
nativeDial
? { type: 'prescienceAnswer', value: 5 }
: { type: 'nexusPrescienceAnswer', event: g.battle!.event, value: 5 },
);
const commitments = viewGame(g, 'p').battle!.ownCommitments;
assert.ok(commitments.some((c) => c.field === 'dial' && c.value === 5));
assert.ok(
commitments.some(
(c) => c.field === unchangedField && c.value === unchangedValue,
),
);
g = prepareSardaukarBattle(g);
if (extraLeader)
nexusReject(
g,
'p',
{
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders.find(
(candidate) => candidate.id !== leader,
)!.id,
},
/Every element revealed by a battle inspection must remain unchanged/,
);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader,
});
assert.equal(g.battle!.plans.p.dial, 5);
nexusSardaukarInventory(g);
}
});
void test('Secret Ally inspection retains private history when canceled Sardaukar require a revised dial', () => {
let g = nexusSardaukarFixture();
const karama = hold(g, 'q', 'karama');
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, 'q', g.players, () => 0);
g = applyAction(g, 'q', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'secretAlly',
field: 'dial',
});
g = applyAction(g, 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: 10,
});
g = beginNexusSardaukar(prepareSardaukarBattle(g));
g = applyAction(g, 'q', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer');
assert.deepEqual(viewGame(g, 'r').battle!.nexusInsights, []);
assert.ok(
viewGame(g, 'q').battle!.nexusInsights.some(
(insight) => insight.value === 10 && !insight.active,
),
);
g = applyAction(nexusReload(g), 'p', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: 5,
});
const answers = viewGame(g, 'q').battle!.nexusInsights;
assert.ok(answers.some((insight) => insight.value === 10 && !insight.active));
assert.ok(answers.some((insight) => insight.value === 5 && insight.active));
g = prepareSardaukarBattle(g);
g = applyAction(g, 'p', {
type: 'battlePlan',
dial: 5,
support: 5,
leader: g.players[0].leaders[0].id,
});
nexusSardaukarInventory(g);
});
+194
View File
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { drawNexusCard } from '../game/nexus-cards';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { maxCombatDial, maxCombatSupport } from '../game/combat';
import {
enterNexusSpice,
orderNexusSpice,
finishNexusSpice,
nexusAllow,
nexusReload,
} from './fixture-nexus-cards';
import {
beginNexusSardaukar,
prepareSardaukarBattle,
nexusSardaukarInventory,
} from './fixture-nexus-sardaukar';
/** Both modules and the final roster precede the genuine setup initializer.
* Later board positions conserve all counters; Salusa is an explicit subset
* of existing reserves, including the low case's four stars at Kaitain. */
function fixture(salusa: 1 | 2 | 5 = 5) {
let g = createGame('SARDHOME', newPlayer('p', 'Emperor', 'emperor'), true);
joinGame(g, newPlayer('q', 'Guild', 'guild'));
joinGame(g, newPlayer('r', 'Fremen', 'fremen'));
g = applyAction(g, 'p', { type: 'homeworlds', enabled: true });
g.nexusCards = { cards: null, phase: null };
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeNexusGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 80; step++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((player) => player.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, 'Combined setup must progress through real choices.');
g = next;
}
assert.equal(g.status, 'playing');
for (const p of g.players) g.deck.push(...p.hand.splice(0));
g = enterNexusSpice(g);
orderNexusSpice(g, ['land', 'land']);
g = finishNexusSpice(g);
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 20;
if (p.elites) {
p.elites.forces = {};
p.elites.reserves = p.faction === 'emperor' ? 5 : 3;
p.elites.tanks = 0;
}
}
g.players[0].forces = { 'pasty_mesa:5': 7 };
g.players[0].reserves = 13;
g.players[1].forces = { 'pasty_mesa:5': 2 };
g.players[1].reserves = 18;
g.homeworlds!.custody!.salusa = { normal: 0, elite: salusa };
const cards = g.nexusCards!.cards!;
cards.deck = ['emperor', ...cards.deck.filter((card) => card !== 'emperor')];
g.nexusCards!.cards = drawNexusCard(cards, 'p', g.players, () => 0);
Object.assign(g, {
phase: 6,
phaseOpening: null,
response: null,
decision: null,
active: 'p',
order: ['p', 'q', 'r'],
ready: [],
storm: 18,
});
g = applyAction(g, 'p', {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: 'q',
});
for (const id of ['p', 'q'])
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
g = prepareSardaukarBattle(g);
inventory(g);
return g;
}
function inventory(g: Game) {
homeworldGameIntegrity(g);
nexusSardaukarInventory(g);
}
function activate(g: Game) {
return nexusAllow(beginNexusSardaukar(g));
}
function plan(g: Game, support: number) {
const leader = [...g.players[0].leaders].sort(
(a, b) => b.strength - a.strength,
)[0];
return { type: 'battlePlan', dial: 12, support, leader: leader.id };
}
function resolve(g: Game, support: number) {
g = applyAction(g, 'p', plan(g, support));
g = applyAction(g, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[1].leaders[0].id,
});
for (const id of ['p', 'q'])
g = applyAction(g, id, { type: 'traitorCall', call: false });
if (g.decision?.kind === 'battleLosses')
g = applyAction(g, 'p', { type: 'decision', choice: 0 });
return g;
}
void test('real high-Salusa Cunning pays only two normal support and all seven losses remain ordinary counters', () => {
const original = fixture();
let g = activate(nexusReload(original));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.deepEqual(
[forces.normal, forces.elite, forces.temporaryElite],
[7, 0, 5],
);
assert.equal(forces.eliteFreeSupport, true);
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), 2);
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
const before = nexusReload(g);
assert.throws(() => applyAction(g, 'p', plan(g, 3)));
assert.deepEqual(g, before);
for (const p of g.players)
assert.deepEqual(viewGame(nexusReload(g), p.id), viewGame(g, p.id));
g = resolve(g, 2);
assert.equal(g.players[0].tanks, 7);
assert.equal(g.players[0].spice, original.players[0].spice - 2);
assert.equal(g.players[0].elites!.tanks, 0);
assert.equal(g.players[0].elites!.reserves, 5);
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 5 });
assert.equal(g.players[0].reserves, original.players[0].reserves);
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
inventory(g);
});
void test('one Salusa star stays low despite five temporary Sardaukar and requires all seven support payments', () => {
const original = fixture(1);
let g = activate(nexusReload(original));
const forces = viewGame(g, 'p').battle!.ownForces!;
assert.equal(forces.temporaryElite, 5);
assert.equal(!!forces.eliteFreeSupport, false);
assert.equal(maxCombatDial(forces), 12);
assert.equal(maxCombatSupport(forces), 7);
const before = nexusReload(g);
assert.throws(() => applyAction(g, 'p', plan(g, 2)));
assert.deepEqual(g, before);
g = resolve(g, 7);
assert.equal(g.players[0].tanks, 7);
assert.equal(g.players[0].spice, original.players[0].spice - 7);
assert.equal(g.players[0].elites!.tanks, 0);
assert.equal(g.players[0].elites!.reserves, 5);
assert.deepEqual(g.homeworlds!.custody!.salusa, { normal: 0, elite: 1 });
inventory(g);
});
void test('the actual Salusa one-to-two threshold controls the shared profile without counting stars at Kaitain', () => {
for (const [salusa, support] of [
[1, 7],
[2, 2],
] as const) {
const g = activate(fixture(salusa));
assert.equal(
maxCombatSupport(viewGame(g, 'p').battle!.ownForces!),
support,
);
assert.equal(g.players[0].elites!.reserves, 5);
assert.equal(g.homeworlds!.custody!.salusa!.elite, salusa);
inventory(g);
}
});
+302
View File
@@ -0,0 +1,302 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import {
nexusSardaukarFixture,
prepareSardaukarBattle,
nexusSardaukarInventory,
} from './fixture-nexus-sardaukar';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(initial: Game) {
const store = unitStore();
const made = await store.rooms.createRoom(
'Cunning SQL',
'harkonnen',
false,
[],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['guild', 'fremen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const ids = ['p', 'q', 'r'];
// Only disposable SQL seat bindings move. Existing game identities and their
// signed battle/component histories remain untouched.
for (const [i, token] of tokens.entries()) {
const auth = await store.rooms.authenticate(code, token);
store.sqlite
.prepare(
'UPDATE seats SET player_id = ? WHERE room_code = ? AND player_id = ?',
)
.run(ids[i], code, auth.playerId);
}
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
initial.code = code;
initial.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initial), initial.version, code);
store.writes.length = 0;
return { ...store, code, tokens, auths, ids, initial };
}
type Fixture = Awaited<ReturnType<typeof fixture>> & { sqlite: DatabaseSync };
const row = (f: Fixture) =>
f.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(f.code);
const sardaukarAction = (g: Game): Action => ({
type: 'nexusSardaukar',
event: viewGame(g, 'p').nexusSardaukar!.offer!.event,
});
async function restored(f: Fixture, g: Game) {
const before = row(f),
rooms = f.restart();
for (const [i, token] of f.tokens.entries()) {
const auth = await rooms.authenticate(f.code, token);
const actual = await rooms.readSeatView(f.code, auth);
assert.deepEqual(actual, viewGame(g, f.ids[i]));
assert.equal(Object.hasOwn(actual, 'nexusSardaukarHistory'), false);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusSardaukarInventory(g);
}
async function compete(f: Fixture, action: Action) {
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const results = await Promise.allSettled(
[f.restart(), f.restart()].map((rooms) =>
rooms.act(f.code, f.auths[0], f.initial.version, action, clock),
),
);
delete f.hooks.beforeWrite;
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
const g = await f.restart().readRoom(f.code);
assert.equal(g.version, f.initial.version + 1);
return g;
}
function initialBattle(sealed = false) {
let g = prepareSardaukarBattle(nexusSardaukarFixture());
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const karama = g.deck.splice(index, 1)[0];
g.players[1].hand.push(karama);
if (sealed)
g = applyAction(g, 'q', {
type: 'battlePlan',
dial: 0,
support: 0,
leader: g.players[1].leaders[0].id,
weapon: null,
defense: null,
});
nexusSardaukarInventory(g);
return { g, karama: karama.id };
}
function physical(g: Game) {
return g.players.map((p) => ({
id: p.id,
forces: p.forces,
reserves: p.reserves,
tanks: p.tanks,
elites: p.elites,
spice: p.spice,
}));
}
void test('SQLite competing Emperor Cunning plays spend one card and restore a sealed opponent plus the pending response privately', async () => {
const { g: initial } = initialBattle(true);
const f = await fixture(initial);
try {
const action = sardaukarAction(initial);
const g = await compete(f, action);
assert.equal(g.response?.kind, 'nexusSardaukar');
assert.equal(g.nexusSardaukarHistory!.length, 1);
assert.equal(g.nexusSardaukarLast!.stage, 'pending');
assert.deepEqual(g.battle!.plans.q, initial.battle!.plans.q);
assert.deepEqual(physical(g), physical(initial));
assert.equal(g.nexusCards!.cards!.hands.p, null);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'emperor').length,
1,
);
await restored(f, g);
for (const id of ['p', 'r'])
assert.equal(viewGame(g, id).battle!.plans.q, undefined);
const before = row(f);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], initial.version, action, clock),
);
await assert.rejects(
f.rooms.act(f.code, f.auths[0], g.version, action, clock),
);
assert.deepEqual(row(f), before);
await f
.restart()
.act(f.code, f.auths[1], g.version, { type: 'passResponse' }, clock);
const active = await f.restart().readRoom(f.code);
assert.equal(active.nexusSardaukarLast!.stage, 'active');
assert.deepEqual(physical(active), physical(initial));
assert.deepEqual(active.battle!.plans.q, initial.battle!.plans.q);
await restored(f, active);
} finally {
f.sqlite.close();
}
});
void test('SQLite lost acknowledgment followed by Karama cancels only temporary strength and retains all real Sardaukar in reserves', async () => {
const { g: initial, karama } = initialBattle();
const f = await fixture(initial);
try {
await f.rooms.act(
f.code,
f.auths[0],
initial.version,
sardaukarAction(initial),
clock,
);
const pending = await f.restart().readRoom(f.code);
await restored(f, pending);
await f
.restart()
.act(
f.code,
f.auths[1],
pending.version,
{ type: 'card', card: karama, mode: 'cancel' },
clock,
);
const canceled = await f.restart().readRoom(f.code);
assert.equal(canceled.nexusSardaukarLast!.stage, 'canceled');
assert.deepEqual(physical(canceled), physical(initial));
assert.equal(canceled.players[0].elites!.reserves, 5);
assert.equal(canceled.players[0].elites!.tanks, 0);
assert.equal(
canceled.discard.filter((card) => card.id === karama).length,
1,
);
assert.equal(
canceled.nexusCards!.cards!.discard.filter((card) => card === 'emperor')
.length,
1,
);
await restored(f, canceled);
const before = row(f);
await assert.rejects(
f.rooms.act(
f.code,
f.auths[1],
canceled.version,
{ type: 'card', card: karama, mode: 'cancel' },
clock,
),
);
assert.deepEqual(row(f), before);
} finally {
f.sqlite.close();
}
});
void test('SQLite damaged Emperor battle source, history, stage or response refuses every seat and writes nothing', async () => {
const { g: initial } = initialBattle();
const f = await fixture(initial);
try {
await f.rooms.act(
f.code,
f.auths[0],
initial.version,
sardaukarAction(initial),
clock,
);
const good = await f.rooms.readRoom(f.code);
const changes: ((g: Game) => void)[] = [
(g) => {
delete g.nexusSardaukarHistory;
},
(g) => {
delete g.nexusSardaukarLast;
},
(g) => {
delete g.battle!.nexusSardaukarUsed;
},
(g) => {
g.battle!.nexusSardaukarUsed = 'stale';
},
(g) => {
g.nexusSardaukarHistory![0].stage = 'active';
},
(g) => {
g.nexusSardaukarHistory![0].receipt.normal++;
},
(g) => {
g.nexusSardaukarHistory![0].receipt.battle = 'stale';
},
(g) => {
g.nexusSardaukarHistory![0].parent += '-stale';
},
(g) => {
g.response = null;
},
(g) => {
g.response!.owner = 'q';
},
(g) => {
g.response!.intent += '-stale';
},
(g) => {
g.players[0].forces['pasty_mesa:5']++;
g.players[0].reserves--;
},
];
for (const change of changes) {
const bad = structuredClone(good);
change(bad);
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(bad), f.code);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, auth));
await assert.rejects(
f
.restart()
.act(
f.code,
f.auths[1],
bad.version,
{ type: 'passResponse' },
clock,
),
);
// Automatic recovery can decline an invalid continuation or surface its
// integrity error; neither path may rewrite the damaged saved row.
await f
.restart()
.continueRoomAutomatic(f.code, clock)
.catch(() => {});
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
}
} finally {
f.sqlite.close();
}
});