Implement Atreides Nexus inspections with durable battle commitments

This commit is contained in:
Quintine committed 2026-09-10 10:02:31 +10:00
1 parent d2a1d63517
commit d820c8a414
25 files changed
+3171 -182

No files matched your search

+460
View File
@@ -0,0 +1,460 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createNexusInspection,
cancelNexusInspection,
answerNexusInspection,
allowNexusInspection,
reopenNexusNative,
answerNexusNative,
reopenNexusInspection,
validateNexusInspection,
committedPlanElements,
inspectedPlanElements,
type BattleInspectionContext,
type NexusInspection,
} from '../game/battle-inspections';
const context = (): BattleInspectionContext => ({
event: 'battle-2',
attacker: 'a',
defender: 'g',
players: [
{ id: 'a', faction: 'atreides' },
{ id: 'g', faction: 'guild' },
{ id: 'h', faction: 'harkonnen' },
],
native: { player: 'a', field: 'dial', value: 0 },
});
const cunning = (c = context()) =>
createNexusInspection(c, {
mode: 'cunning',
owner: 'a',
target: 'g',
field: 'weapon',
});
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
void test('Cunning snapshots a real first answer including zero and null and requires a distinct second element', () => {
for (const native of [
{ player: 'a', field: 'dial' as const, value: 0 },
{ player: 'a', field: 'leader' as const, value: null },
]) {
const c = { ...context(), native };
const before = JSON.stringify(c);
const inspection = cunning(c);
assert.deepEqual(inspection.first, native);
assert.equal(inspection.stage, 'response');
assert.deepEqual(inspection.answers, []);
assert.throws(() =>
createNexusInspection(c, {
mode: 'cunning',
owner: 'a',
target: 'g',
field: native.field,
}),
);
assert.equal(JSON.stringify(c), before);
const unanswered = {
...c,
native: { player: native.player, field: native.field },
};
assert.throws(() => cunning(unanswered));
assert.throws(() => cunning({ ...c, native: undefined }));
}
});
void test('Cunning binds the current native answer and permits changes only through recorded Residual transitions', () => {
const c = context();
const inspection = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
null,
);
const pending = { ...c, native: { player: 'a', field: 'dial' as const } };
const changed = {
...c,
native: { player: 'a', field: 'dial' as const, value: 2.5 },
};
assert.throws(() => validateNexusInspection(pending, inspection));
assert.throws(() => validateNexusInspection(changed, inspection));
const reopened = reopenNexusNative(c, inspection);
assert.equal(Object.hasOwn(reopened, 'firstCurrent'), false);
validateNexusInspection(pending, reopened);
assert.throws(() => reopenNexusNative(pending, reopened));
assert.throws(() => answerNexusNative(c, inspection, 2.5));
const answered = answerNexusNative(pending, reopened, 2.5);
validateNexusInspection(changed, answered);
assert.deepEqual(answered.firstCurrent, { value: 2.5 });
assert.equal(answered.first!.value, 0);
assert.equal(inspection.firstCurrent!.value, 0);
assert.throws(() => validateNexusInspection(c, answered));
assert.throws(() =>
validateNexusInspection({ ...c, native: undefined }, answered),
);
const canceled = cancelNexusInspection(c, cunning(c));
const canceledReopened = reopenNexusNative(c, canceled);
validateNexusInspection(pending, canceledReopened);
assert.equal(
answerNexusNative(pending, canceledReopened, 1).stage,
'canceled',
);
});
void test('reopened non-leader inspection retains immutable disclosure history but only its latest answered value binds', () => {
const c = context();
const first = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
'treachery-1',
);
const firstJson = JSON.stringify(first);
const reopened = reopenNexusInspection(c, reload(first));
const battle = {
event: c.event,
attacker: c.attacker,
defender: c.defender,
prescience: c.native,
nexusInspection: reopened,
};
assert.deepEqual(committedPlanElements(battle, 'g'), [
{
source: 'native',
beneficiary: 'a',
target: 'g',
field: 'dial',
value: 0,
},
]);
const second = answerNexusInspection(c, reopened, null);
assert.deepEqual(second.answers, ['treachery-1', null]);
assert.deepEqual(
committedPlanElements({ ...battle, nexusInspection: second }, 'g').at(-1),
{
source: 'nexus',
beneficiary: 'a',
target: 'g',
field: 'weapon',
value: null,
},
);
assert.deepEqual(
inspectedPlanElements({ ...battle, nexusInspection: second }, 'a'),
[],
);
assert.deepEqual(
committedPlanElements({ ...battle, nexusInspection: second }, 'h'),
[],
);
assert.equal(JSON.stringify(first), firstJson);
assert.deepEqual(reopened.answers, ['treachery-1']);
assert.throws(() => reopenNexusInspection(c, reopened));
assert.throws(() => answerNexusInspection(c, second, 'treachery-2'));
});
void test('Secret Ally requires an absent Atreides and an actual opposing combatant; leaders never reopen', () => {
const c: BattleInspectionContext = {
event: 'absent',
attacker: 'h',
defender: 'g',
players: [
{ id: 'h', faction: 'harkonnen' },
{ id: 'g', faction: 'guild' },
{ id: 'e', faction: 'emperor' },
],
};
const input = {
mode: 'secretAlly' as const,
owner: 'h',
target: 'g',
field: 'leader' as const,
};
const inspection = answerNexusInspection(
c,
createNexusInspection(c, input),
null,
);
assert.throws(() => reopenNexusInspection(c, inspection));
assert.throws(() =>
createNexusInspection(
{ ...c, players: [...c.players, { id: 'a', faction: 'atreides' }] },
input,
),
);
assert.throws(() => createNexusInspection(c, { ...input, owner: 'e' }));
assert.throws(() => createNexusInspection(c, { ...input, target: 'e' }));
assert.throws(() => createNexusInspection(c, { ...input, mode: 'cunning' }));
validateNexusInspection(reload(c), reload(inspection));
});
void test('Betrayal binds a pending native attempt even for an allied beneficiary and a third-party holder', () => {
const c: BattleInspectionContext = {
...context(),
attacker: 'h',
native: { player: 'h', field: 'defense' },
};
const input = {
mode: 'betrayal' as const,
owner: 'g',
target: 'g',
field: 'defense' as const,
};
const inspection = createNexusInspection(c, input);
assert.equal(inspection.nativeProvider, 'a');
assert.equal(inspection.nativeBeneficiary, 'h');
assert.equal(inspection.stage, 'canceled');
assert.deepEqual(inspection.answers, []);
validateNexusInspection({ ...c, native: undefined }, reload(inspection));
assert.deepEqual(
committedPlanElements({ ...c, nexusInspection: inspection }, 'g'),
[],
);
assert.throws(() => answerNexusInspection(c, inspection, null));
assert.throws(() => reopenNexusInspection(c, inspection));
assert.throws(() =>
createNexusInspection(
{ ...c, native: { ...c.native!, value: null } },
input,
),
);
assert.throws(() =>
createNexusInspection({ ...c, native: undefined }, input),
);
assert.throws(() => createNexusInspection(c, { ...input, owner: 'a' }));
const outside = {
...c,
players: [...c.players, { id: 'e', faction: 'emperor' as const }],
};
validateNexusInspection(
outside,
createNexusInspection(outside, { ...input, owner: 'e' }),
);
});
void test('bound receipt rejects battle, owner, target, field, history and stage corruption without mutation', () => {
const c = context();
const original = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
null,
);
const corruptions: Array<(i: NexusInspection) => void> = [
(i) => {
i.event = 'another';
},
(i) => {
i.owner = 'h';
},
(i) => {
i.target = 'a';
},
(i) => {
i.field = 'defense';
},
(i) => {
i.stage = 'answer';
},
(i) => {
i.answers = [];
},
(i) => {
i.first!.value = 1;
},
(i) => {
delete i.first;
},
(i) => {
Object.assign(i, { hand: ['secret'] });
},
(i) => {
i.signature = '';
},
];
for (const corrupt of corruptions) {
const candidate = reload(original);
corrupt(candidate);
const before = JSON.stringify(candidate);
assert.throws(() => validateNexusInspection(c, candidate));
assert.throws(() => reopenNexusInspection(c, candidate));
assert.equal(JSON.stringify(candidate), before);
}
assert.throws(() =>
validateNexusInspection({ ...c, event: 'another' }, original),
);
assert.throws(() =>
committedPlanElements(
{
attacker: 'a',
defender: 'g',
event: 'another',
nexusInspection: original,
},
'g',
),
);
});
void test('typed answers allow half-force dials and no-card null but reject invalid serialized values', () => {
const c: BattleInspectionContext = {
event: 'dial',
attacker: 'h',
defender: 'g',
players: [
{ id: 'h', faction: 'harkonnen' },
{ id: 'g', faction: 'guild' },
],
};
const waiting = createNexusInspection(c, {
mode: 'secretAlly',
owner: 'h',
target: 'g',
field: 'dial',
});
assert.deepEqual(answerNexusInspection(c, waiting, 0.5).answers, [0.5]);
for (const value of [
null,
'2',
-1,
NaN,
Infinity,
0.25,
Number.MAX_SAFE_INTEGER,
])
assert.throws(() => answerNexusInspection(c, waiting, value));
const weapon = createNexusInspection(c, {
mode: 'secretAlly',
owner: 'h',
target: 'g',
field: 'weapon',
});
for (const value of [0, '', ' ', undefined])
assert.throws(() =>
answerNexusInspection(c, weapon, value as unknown as null),
);
});
void test('public-only context never reads hands, plans, resources or unrelated private state', () => {
const c = context();
for (const player of c.players)
for (const key of ['hand', 'spice', 'traitors'])
Object.defineProperty(player, key, {
get() {
throw new Error(`Private ${key} accessed`);
},
});
Object.defineProperty(c, 'plans', {
get() {
throw new Error('Plans accessed');
},
});
const before = JSON.stringify(c);
const answered = answerNexusInspection(
c,
allowNexusInspection(c, cunning(c)),
null,
);
validateNexusInspection(c, reload(answered));
assert.equal(JSON.stringify(c), before);
assert.deepEqual(Object.keys(answered).sort(), [
'answers',
'event',
'field',
'first',
'firstCurrent',
'mode',
'owner',
'signature',
'stage',
'target',
'version',
]);
});
void test('canceling the original Cunning extra attempt preserves its first disclosure and cannot cancel other sources or old answers', () => {
const c = context();
const waiting = cunning(c);
const before = JSON.stringify(waiting);
const canceled = cancelNexusInspection(c, waiting);
assert.equal(canceled.stage, 'canceled');
assert.deepEqual(canceled.answers, []);
assert.deepEqual(canceled.first, waiting.first);
validateNexusInspection(c, reload(canceled));
assert.deepEqual(
committedPlanElements(
{
event: c.event,
attacker: c.attacker,
defender: c.defender,
prescience: c.native,
nexusInspection: canceled,
},
'g',
),
[
{
source: 'native',
beneficiary: 'a',
target: 'g',
field: 'dial',
value: 0,
},
],
);
assert.equal(JSON.stringify(waiting), before);
assert.throws(() => cancelNexusInspection(c, canceled));
assert.throws(() => answerNexusInspection(c, canceled, null));
assert.throws(() => reopenNexusInspection(c, canceled));
const answered = answerNexusInspection(
c,
allowNexusInspection(c, waiting),
null,
);
assert.throws(() => cancelNexusInspection(c, answered));
assert.throws(() =>
cancelNexusInspection(c, reopenNexusInspection(c, answered)),
);
const absent: BattleInspectionContext = {
...c,
attacker: 'h',
native: undefined,
players: c.players.filter((p) => p.faction !== 'atreides'),
};
const secret = createNexusInspection(absent, {
mode: 'secretAlly',
owner: 'h',
target: 'g',
field: 'weapon',
});
assert.throws(() => cancelNexusInspection(absent, secret));
const pending = { ...c, native: { player: 'a', field: 'weapon' as const } };
const betrayal = createNexusInspection(pending, {
mode: 'betrayal',
owner: 'h',
target: 'g',
field: 'weapon',
});
assert.throws(() => cancelNexusInspection(pending, betrayal));
});
void test('Cunning cannot answer or reopen before its original response is explicitly allowed', () => {
const c = context();
const response = cunning(c);
const before = JSON.stringify(response);
assert.equal(response.stage, 'response');
assert.throws(() => answerNexusInspection(c, response, null));
assert.throws(() => reopenNexusInspection(c, response));
const allowed = allowNexusInspection(c, response);
assert.equal(allowed.stage, 'answer');
assert.deepEqual(allowed.first, response.first);
assert.equal(JSON.stringify(response), before);
assert.throws(() => allowNexusInspection(c, allowed));
assert.throws(() => cancelNexusInspection(c, allowed));
const rewritten = reload(response);
rewritten.stage = 'answer';
assert.throws(() => validateNexusInspection(c, rewritten));
const answered = answerNexusInspection(c, allowed, null);
assert.throws(() => allowNexusInspection(c, answered));
const reopened = reopenNexusInspection(c, answered);
assert.throws(() => allowNexusInspection(c, reopened));
assert.throws(() => cancelNexusInspection(c, reopened));
});
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeNexusGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { baseDeck, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { deployRicheseNoField } from '../game/richese-no-field';
import { validateNexusCards } from '../game/nexus-cards';
import { drawNexusCard } from '../game/nexus-cards';
import {
nexusFixture,
enterNexusSpice,
finishNexusSpice,
orderNexusSpice,
nexusInventory,
nexusPlayer,
nexusAllow,
} from './fixture-nexus-cards';
/** Genuine final roster and component setup, followed by a conserved battle
* boundary. No faction, seat identity or signed history is changed afterward. */
export function nexusInspectionFixture(
secretAlly = false,
holder = 'a',
options: { richese?: boolean; noField?: boolean } = {},
): Game {
let g: Game;
if (!secretAlly && !options.richese && !options.noField) g = nexusFixture();
else {
g = createGame(
'INSPECT',
newPlayer('f', 'Fremen', 'fremen'),
false,
[], // The explicit audit includes Richese's printed cache and markers; E2 deck release remains gated.
);
joinGame(
g,
newPlayer(
'a',
secretAlly ? 'Guild' : 'Atreides',
secretAlly ? 'guild' : 'atreides',
),
);
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
// Public lobby placeholders are replaced before any initialized components/history.
if (options.noField) g.players[2] = newPlayer('h', 'Richese', 'richese');
else if (options.richese)
g.players[0] = newPlayer('f', 'Richese', 'richese');
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g.nexusCards = { cards: null, phase: null };
g = initializeNexusGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 60; i++) {
let next: Game | undefined;
for (const p of g.players) {
const v = viewGame(g, p.id);
v.players.find((seat) => seat.id === p.id)!.bot = 'Easy';
const action = botActions(v)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next, `Setup stalled at ${g.setupStage}`);
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);
Object.assign(g, {
phase: 6,
active: 'a',
order: ['a', 'h', 'f'],
ready: [],
storm: 18,
response: null,
decision: null,
phaseOpening: null,
});
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 20;
}
for (const id of ['a', 'h']) {
nexusPlayer(g, id).forces = { 'pasty_mesa:5': 5 };
nexusPlayer(g, id).reserves = 15;
}
const cards = g.nexusCards!.cards!;
cards.deck = [
'atreides',
...cards.deck.filter((card) => card !== 'atreides'),
];
g.nexusCards!.cards = drawNexusCard(cards, holder, g.players, () => 0);
if (options.richese || options.noField) inspectionInventory(g);
else nexusInventory(g);
if (options.noField) {
const target = nexusPlayer(g, 'h');
assert.ok(target.noField);
target.forces = {};
target.reserves = 20;
target.noField = deployRicheseNoField(target.noField, {
tokenId: target.noField.tokens.find((t) => t.value === 3)!.id,
controller: 'h',
location: { territory: 'pasty_mesa', sector: 5 },
});
}
g = applyAction(g, 'a', {
type: 'chooseBattle',
territory: 'pasty_mesa',
target: 'h',
});
for (const id of ['a', 'h'])
if (g.battle?.preLeader && !g.battle.preLeader.closed)
g = applyAction(g, id, {
type: 'battlePreparationReady',
event: g.battle!.event,
});
return g;
}
export function inspectionHold(g: Game, id: string, kind: Card['kind']): Card {
const index = g.deck.findIndex((card) => card.kind === kind);
assert.ok(index >= 0, `Missing physical ${kind}`);
const [card] = g.deck.splice(index, 1);
nexusPlayer(g, id).hand.push(card);
return card;
}
export function inspectionNative(
g: Game,
field: 'leader' | 'weapon' | 'defense' | 'dial' = 'dial',
value: string | number | null = 0,
): Game {
assert.equal(g.battle?.preparation?.kind, 'prescience');
g = nexusAllow(applyAction(g, 'a', { type: 'prescience', field }));
return applyAction(g, 'h', { type: 'prescienceAnswer', value });
}
export function inspectionInventory(g: Game): void {
validateNexusCards(g.nexusCards!.cards!, g.players);
assert.deepEqual(
[
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
]
.map((c) => c.id)
.sort(),
[
...baseDeck(),
...(g.players.some((p) => p.faction === 'richese') ? richeseCards() : []),
]
.map((c) => c.id)
.sort(),
);
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
}
export function inspectionResidual(g: Game, holder = 'a'): string {
const index = g.richeseCache!.findIndex(
(c) => c.id === 'richese-residual-poison',
);
assert.ok(index >= 0);
const card = g.richeseCache!.splice(index, 1)[0];
nexusPlayer(g, holder).hand.push(card);
return card.id;
}
+87
View File
@@ -0,0 +1,87 @@
import { webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import type * as Rooms from '../db/rooms';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
export function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
+5 -85
View File
@@ -1,93 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, webcrypto } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
import * as engine from '../game/engine';
import * as bots from '../game/bots';
import type * as Rooms from '../db/rooms';
import { nexusTurnTwo, nexusReady, orderNexusSpice, finishNexusSpice, nexusInventory } from './fixture-nexus-cards';
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
function unitStore(runBots = bots.runBots) {
const sqlite = new DatabaseSync(':memory:');
const hooks: { beforeWrite?: () => Promise<void> } = {};
const writes: { expected: number; changes: number }[] = [];
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
.filter((f) => f.endsWith('.sql'))
.sort())
sqlite.exec(
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
);
class Statement {
values: (string | number | null)[] = [];
constructor(readonly sql: string) {}
bind(...values: (string | number | null)[]) {
this.values = values;
return this;
}
async first() {
return sqlite.prepare(this.sql).get(...this.values) ?? null;
}
async run() {
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
sqlite.prepare(this.sql).run(...this.values).changes,
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
return { meta: { changes } };
}
}
const database = {
prepare: (sql: string) => new Statement(sql),
batch: async (statements: Statement[]) => {
sqlite.exec('BEGIN');
try {
const results = [];
for (const statement of statements) results.push(await statement.run());
sqlite.exec('COMMIT');
return results;
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
function loadRooms() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
crypto: webcrypto,
TextEncoder,
structuredClone,
// Keep persisted JSON values in the test realm for strict structural assertions.
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers') return { env: { DB: database } };
if (name === '@/game/engine') return engine;
if (name === '@/game/bots') return { ...bots, runBots };
throw new Error('Unexpected module ' + name);
},
},
);
return exports as typeof Rooms;
}
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
}
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
@@ -118,7 +38,7 @@ async function persisted() {
store.writes.length = 0;
return {...store,code,credentials,auths,ids,initial,save,otherCode:other.view.code,otherBefore};
}
type Fixture = Awaited<ReturnType<typeof persisted>>;
type Fixture = Awaited<ReturnType<typeof persisted>> & { sqlite: DatabaseSync };
function snapshot(f: Fixture) {
const row = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(f.code)!;
return {version:row.version,hash:createHash('sha256').update(String(row.state)).digest('hex')};
+327
View File
@@ -0,0 +1,327 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type GameView,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { cashInCards } from '../game/choam-karama';
import {
createNexusInspection,
allowNexusInspection,
answerNexusInspection,
reopenNexusInspection,
} from '../game/battle-inspections';
/** These are consumer tests of authoritative projected fields, not evidence
* of Nexus acquisition or effect execution. The underlying ordinary battle
* supplies real public map/leader/card shapes and conserved physical cards. */
function fixture(): GameView {
let g = createGame('NEXUSBOTS', newPlayer('e', 'Emperor', 'emperor'));
g.players.push(
newPlayer('q', 'Guild', 'guild'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 6,
turn: 2,
storm: 18,
active: 'e',
order: ['e', 'q', 'h'],
deck: baseDeck(),
});
for (const p of g.players)
Object.assign(p, { forces: {}, reserves: 20, spice: 20, hand: [] });
for (const p of g.players.slice(0, 2)) {
p.forces = { 'arrakeen:10': 5 };
p.reserves = 15;
}
for (const kind of ['projectile', 'shield', 'poison', 'snooper'] as const) {
const index = g.deck.findIndex((c) => c.kind === kind);
g.players[0].hand.push(g.deck.splice(index, 1)[0]);
}
g = applyAction(g, 'e', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'q',
});
const v = viewGame(g, 'e');
v.battle!.ownCommitments = [];
v.battle!.nexusInsights = [];
v.battle!.nexusInspection = null;
v.nexusAtreides = null;
return v;
}
const own = (v: GameView) => v.players.find((p) => p.id === v.me)!;
void test('all four profiles preserve simultaneous native and Nexus commitments including zero and null', () => {
for (const difficulty of DIFFICULTIES)
for (const fields of ['cards', 'dial', 'leader'] as const) {
const v = fixture(),
me = own(v),
b = v.battle!;
me.bot = difficulty;
const weapon = me.hand!.find((c) => c.kind === 'projectile')!.id;
b.ownCommitments =
fields === 'cards'
? [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'weapon',
value: weapon,
},
{
source: 'nexus',
beneficiary: 'q',
target: 'e',
field: 'defense',
value: null,
},
]
: fields === 'dial'
? [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'weapon',
value: weapon,
},
{
source: 'nexus',
beneficiary: 'q',
target: 'e',
field: 'dial',
value: 0,
},
]
: [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'leader',
value: me.leaders[1].id,
},
{
source: 'nexus',
beneficiary: 'q',
target: 'e',
field: 'dial',
value: 2,
},
];
const before = structuredClone(v),
actions = botActions(v);
assert.ok(actions.length);
for (const action of actions) {
assert.equal(action.type, 'battlePlan');
for (const element of b.ownCommitments)
assert.equal(action[element.field], element.value);
}
assert.deepEqual(v, before);
}
});
void test('pending Nexus answers bind the battle event and preserve the earlier native weapon', () => {
for (const difficulty of DIFFICULTIES) {
const v = fixture(),
me = own(v),
b = v.battle!;
me.bot = difficulty;
const weapon = me.hand!.find((c) => c.kind === 'projectile')!.id;
b.ownCommitments = [
{
source: 'native',
beneficiary: 'q',
target: 'e',
field: 'weapon',
value: weapon,
},
];
b.preparation = {
kind: 'nexusPrescienceAnswer',
owner: 'e',
beneficiary: 'q',
};
b.nexusInspection = {
event: b.event!,
mode: 'cunning',
owner: 'q',
target: 'e',
field: 'defense',
stage: 'answer',
};
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions) {
assert.equal(action.type, 'nexusPrescienceAnswer');
assert.equal(action.event, b.event);
assert.ok(
action.value === null ||
me.hand!.some(
(c) =>
c.id === action.value && ['shield', 'snooper'].includes(c.kind),
),
);
}
b.nexusInspection.stage = 'response';
assert.deepEqual(botActions(v), []);
b.nexusInspection.stage = 'answer';
b.nexusInspection.event = 'old-battle';
assert.deepEqual(botActions(v), []);
}
});
void test('all profiles choose only an offered distinct Nexus field and respect public interruption fences', () => {
for (const difficulty of DIFFICULTIES) {
const v = fixture();
own(v).bot = difficulty;
v.nexusCards = {
card: 'atreides',
deckCount: 11,
discardCount: 0,
held: { e: true, q: false, h: false },
turn: 2,
choices: [],
waiting: [],
};
v.nexusAtreides = {
event: v.battle!.event!,
mode: 'cunning',
fields: ['defense', 'leader', 'dial'],
blocked: null,
};
assert.deepEqual(botActions(v), [
{
type: 'nexusAtreides',
event: v.battle!.event,
mode: 'cunning',
field: 'defense',
},
]);
v.nexusAtreides.mode = 'secretAlly';
v.nexusAtreides.fields = ['weapon', 'dial'];
assert.equal(
botActions(v)[0].field,
['Easy', 'Medium'].includes(difficulty) ? 'dial' : 'weapon',
);
for (const edit of [
(x: GameView) => {
x.nexusAtreides!.blocked = 'Awaiting timing ruling';
},
(x: GameView) => {
x.nexusAtreides!.event = 'old-battle';
},
(x: GameView) => {
x.nexusCards!.card = 'ecaz';
},
(x: GameView) => {
x.automaticContinuationPending = true;
},
(x: GameView) => {
x.phaseOpening = { passed: [] };
},
(x: GameView) => {
x.response = { kind: 'voice', owner: 'q', passed: [] };
},
]) {
const blocked = structuredClone(v);
edit(blocked);
assert.ok(botActions(blocked).every((a) => a.type !== 'nexusAtreides'));
}
v.nexusCards.waiting = ['q'];
assert.deepEqual(botActions(v), []);
}
});
void test('private enemy fields are never consulted and retired Nexus observations are not current predictions', () => {
const v = fixture();
own(v).bot = 'Hard';
const base = botActions(v);
v.battle!.nexusInspection = {
event: v.battle!.event!,
mode: 'secretAlly',
owner: 'e',
target: 'q',
field: 'weapon',
stage: 'answer',
};
v.battle!.nexusInsights = [
{
field: 'weapon',
value: 'treachery-3',
label: 'Historical only',
active: false,
},
];
for (const p of v.players.filter((p) => p.id !== v.me))
for (const field of ['hand', 'spice', 'traitors'])
Object.defineProperty(p, field, {
get() {
throw Error(`Read private ${field}`);
},
configurable: true,
});
assert.deepEqual(botActions(v), base);
});
void test('CHOAM cash-in reserves both answered sources but releases a retired Nexus binding without losing its history', () => {
const state = createGame('NEXUSCASH', newPlayer('a', 'Atreides', 'atreides'));
state.players.push(newPlayer('c', 'CHOAM', 'choam'));
const cards = baseDeck()
.filter((c) => c.kind === 'worthless')
.slice(0, 3),
p = state.players[1];
p.hand = cards;
const native = { player: 'a', field: 'weapon' as const, value: cards[0].id };
const context = {
event: 'battle-cash',
attacker: 'a',
defender: 'c',
players: state.players,
native,
};
const inspection = answerNexusInspection(
context,
allowNexusInspection(
context,
createNexusInspection(context, {
mode: 'cunning',
owner: 'a',
target: 'c',
field: 'defense',
}),
),
cards[1].id,
);
state.battle = {
event: context.event,
territory: 'arrakeen',
attacker: 'a',
defender: 'c',
plans: {},
revealed: false,
traitorCalls: {},
prescience: native,
nexusInspection: inspection,
};
assert.deepEqual(
cashInCards(state, p).map((c) => c.id),
[cards[2].id],
);
state.battle.nexusInspection = reopenNexusInspection(context, inspection);
assert.deepEqual(
cashInCards(state, p).map((c) => c.id),
[cards[1].id, cards[2].id],
);
assert.deepEqual(state.battle.nexusInspection.answers, [cards[1].id]);
});
+219
View File
@@ -0,0 +1,219 @@
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 { createGame, newPlayer, viewGame, type GameView, type PlanField } from '../game/engine';
import { baseDeck } from '../game/cards';
import {
BattlePreparation, NexusInspectionHistory, battlePlanCommitments,
bindBattlePlanCommitments, battlePreparationField, battleInspectionAnswerAction,
} from '../components/battle-preparation';
const aliases = registerHooks({ resolve(specifier, context, nextResolve) {
return nextResolve(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
}, load(url, context, nextLoad) {
if (!url.endsWith('.module.css')) return nextLoad(url, context);
// Node has no CSS-module loader. Preserve real selector names for structural
// rendering; this does not replace any component or claim visual CSS QA.
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
} });
const { NexusCards, nexusAtreidesAction } = await import('../components/nexus-cards');
const { GameTable } = await import('../components/game-table');
aliases.deregister();
/** Presentation fixture: full ordinary view plus explicit private projections.
* Engine/recovery suites separately prove how those projections are earned. */
function fixture(me = 'a'): GameView {
const g = createGame('NEXUSINSPECTUI', newPlayer('a', 'Atreides', 'atreides'));
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('e', 'Emperor', 'emperor'));
Object.assign(g, { status: 'playing', phase: 6, turn: 2, storm: 18, active: 'a', order: ['a', 'h', 'e'] });
g.deck = baseDeck();
for (const player of g.players) {
player.hand = [];
player.forces = player.id === 'e' ? {} : { 'arrakeen:10': 4 };
player.reserves = player.id === 'e' ? 20 : 16;
player.spice = 10;
player.traitors = [];
}
g.battle = { event: 'battle-ui', territory: 'arrakeen', attacker: 'a', defender: 'h',
prepared: true, plans: {}, revealed: false, traitorCalls: {} };
const view = viewGame(g, me);
view.nexusCards = { card: 'atreides', deckCount: 11, discardCount: 0,
held: { a: true, h: false, e: false }, turn: null, choices: [], waiting: [] };
view.nexusAtreides = { event: 'battle-ui', mode: 'cunning', fields: ['leader', 'defense', 'dial'], blocked: null };
view.battle!.ownCommitments = [];
view.battle!.nexusInsights = [];
return view;
}
function nexusHtml(view: GameView, busy = false) {
return renderToStaticMarkup(createElement(NexusCards, { game: view, act() {}, busy }));
}
function tableHtml(view: GameView) {
return renderToStaticMarkup(createElement(GameTable, { game: view, send: async () => {}, onExit() {}, busy: false }));
}
function inspection(view: GameView, field: PlanField = 'defense') {
view.battle!.nexusInspection = { event: 'battle-ui', mode: 'cunning', owner: 'a', target: 'h', field, stage: 'answer' };
view.battle!.preparation = { kind: 'nexusPrescienceAnswer', owner: 'h', beneficiary: 'a' };
}
void test('Cunning offers only projected distinct fields and binds the exact battle event', () => {
const view = fixture();
assert.deepEqual(nexusAtreidesAction(view, 'defense'), { type: 'nexusAtreides', event: 'battle-ui', mode: 'cunning', field: 'defense' });
assert.equal(nexusAtreidesAction(view, 'weapon'), null);
const html = nexusHtml(view);
assert.match(html, /Use Cunning: inspect defense/);
assert.doesNotMatch(html, /Use Cunning: inspect weapon/);
assert.match(nexusHtml(view, true), /<button[^>]*disabled=""[^>]*>Use Cunning: inspect defense/);
for (const change of [
(v: GameView) => { v.nexusAtreides!.event = 'another-battle'; },
(v: GameView) => { v.nexusAtreides!.blocked = 'Finish the current response.'; },
(v: GameView) => { v.battle!.submitted.push('a'); },
(v: GameView) => { v.battle!.revealed = true; },
(v: GameView) => { v.phase = 5; },
(v: GameView) => { v.status = 'finished'; },
(v: GameView) => { v.me = 'h'; },
(v: GameView) => { v.nexusCards!.card = 'fremen'; },
]) {
const stale = fixture(); change(stale);
assert.equal(nexusAtreidesAction(stale, 'defense'), null);
}
});
void test('Secret Ally follows projected dial access even with a public No-Field; Cunning cannot add an excluded dial', () => {
const view = fixture();
view.players[0].faction = 'fremen';
view.nexusAtreides!.mode = 'secretAlly';
view.nexusAtreides!.fields = ['leader', 'weapon', 'defense', 'dial'];
view.battle!.noFieldPlayers = ['h'];
assert.deepEqual(nexusAtreidesAction(view, 'dial'), { type: 'nexusAtreides', event: 'battle-ui', mode: 'secretAlly', field: 'dial' });
assert.match(nexusHtml(view), /Use Secret Ally: inspect dial/);
view.players[0].faction = 'atreides';
view.nexusAtreides!.mode = 'cunning';
view.nexusAtreides!.fields = ['leader', 'defense'];
assert.equal(nexusAtreidesAction(view, 'dial'), null);
});
void test('Betrayal uses a card-bound reaction without inventing a field or bypassing a pending server guard', () => {
const view = fixture('e');
view.nexusAtreides!.mode = 'betrayal';
view.nexusAtreides!.fields = [];
assert.deepEqual(nexusAtreidesAction(view), { type: 'nexusAtreides', event: 'battle-ui', mode: 'betrayal' });
assert.equal(nexusAtreidesAction(view, 'dial'), null);
assert.match(nexusHtml(view), />Use Betrayal</);
view.nexusAtreides!.blocked = 'Response timing is awaiting a ruling.';
const html = nexusHtml(view);
assert.match(html, /Response timing is awaiting a ruling/);
assert.doesNotMatch(html, />Use Betrayal</);
});
void test('Nexus answer selects its own field, preserving the native action and binding the new event', () => {
const view = fixture('h');
inspection(view);
view.battle!.prescience = { player: 'a', field: 'weapon', answered: true };
assert.equal(battlePreparationField(view), 'defense');
assert.deepEqual(battleInspectionAnswerAction(view, ''), { type: 'nexusPrescienceAnswer', event: 'battle-ui', value: null });
const html = renderToStaticMarkup(createElement(BattlePreparation, { game: view, send: async () => {}, busy: false }));
assert.match(html, /Reveal your defense/);
assert.doesNotMatch(html, /Reveal your weapon/);
view.battle!.nexusInspection!.event = 'old-battle';
assert.equal(battleInspectionAnswerAction(view, ''), null);
view.battle!.nexusInspection!.event = 'battle-ui';
view.battle!.nexusInspection!.target = 'e';
assert.equal(battleInspectionAnswerAction(view, ''), null);
view.battle!.nexusInspection!.target = 'h';
view.battle!.nexusInspection!.field = 'dial';
assert.deepEqual(battleInspectionAnswerAction(view, '2.5'), { type: 'nexusPrescienceAnswer', event: 'battle-ui', value: 2.5 });
for (const value of ['', ' ', 'NaN', 'Infinity', '-1', '0.25']) assert.equal(battleInspectionAnswerAction(view, value), null);
view.battle!.nexusInspection!.stage = 'answered';
assert.equal(battleInspectionAnswerAction(view, '2'), null);
view.battle!.preparation!.kind = 'prescienceAnswer';
assert.equal(battlePreparationField(view), 'weapon');
assert.deepEqual(battleInspectionAnswerAction(view, 'projectile-1'), { type: 'prescienceAnswer', value: 'projectile-1' });
view.me = 'e';
assert.equal(battleInspectionAnswerAction(view, 'projectile-1'), null);
});
void test('every simultaneous own commitment overrides plan submission, including zero and None, without affecting unrelated fields', () => {
const view = fixture('h');
view.battle!.ownCommitments = [
{ source: 'native', beneficiary: 'a', target: 'h', field: 'dial', value: 0 },
{ source: 'nexus', beneficiary: 'a', target: 'h', field: 'defense', value: null },
];
const plan = { type: 'battlePlan', dial: 4, defense: 'snooper', leader: 'harkonnen-0', weapon: 'projectile-1', support: 3 };
const before = JSON.stringify(view);
assert.deepEqual(Object.keys(battlePlanCommitments(view)).sort(), ['defense', 'dial']);
assert.deepEqual(bindBattlePlanCommitments(view, plan), { ...plan, dial: 0, defense: null });
assert.equal(plan.dial, 4);
assert.equal(JSON.stringify(view), before);
view.me = 'a';
assert.deepEqual(bindBattlePlanCommitments(view, plan), plan);
view.battle!.ownCommitments = [];
assert.deepEqual(battlePlanCommitments(view), {});
});
void test('the actual battle table locks each native-plus-Nexus field pair to the same values used by submission', () => {
for (const fields of [['dial', 'defense'], ['leader', 'weapon']] as const) {
const view = fixture('h');
view.nexusAtreides = null;
view.nexusCards = null;
const values = { dial: 0, defense: null, leader: view.players.find((p) => p.id === 'h')!.leaders[0].id, weapon: null };
view.battle!.ownCommitments = fields.map((field, index) => ({ source: index ? 'nexus' : 'native', beneficiary: 'a', target: 'h', field, value: values[field] }));
const html = tableHtml(view);
for (const field of fields) {
const id = field === 'dial' ? 'forces-dialed' : `battle-${field}`;
const control = html.match(new RegExp(`<[^>]+id="${id}"[^>]*>`))?.[0];
assert.ok(control, `${field} control is visible`);
assert.match(control, /disabled=""|aria-disabled="true"/);
if (field === 'dial') assert.match(control, /value="0"/);
else {
const select = html.match(new RegExp(`<select[^>]+id="${id}"[^>]*>[\\s\\S]*?</select>`))?.[0];
assert.ok(select);
assert.match(select, new RegExp(`<option[^>]+value="${values[field] ?? ''}"[^>]+selected=""`));
}
}
for (const field of ['leader', 'weapon', 'defense'] as const) if (!(fields as readonly string[]).includes(field)) {
const control = html.match(new RegExp(`<select[^>]+id="battle-${field}"[^>]*>`))?.[0];
assert.ok(control);
assert.doesNotMatch(control, /disabled=""/);
}
const action = bindBattlePlanCommitments(view, { type: 'battlePlan', dial: 4, leader: 'wrong', weapon: 'wrong', defense: 'wrong' });
for (const field of fields) assert.equal(action[field], values[field]);
}
});
void test('private Nexus history distinguishes superseded answers and remains visible after the card was spent', () => {
const view = fixture();
inspection(view);
view.battle!.nexusInspection!.stage = 'answered';
view.nexusCards!.card = null;
view.nexusAtreides = null;
view.battle!.nexusInsights = [
{ field: 'defense', value: 'old-defense', label: 'Old defense', active: false },
{ field: 'defense', value: null, label: 'None', active: true },
];
const render = () => renderToStaticMarkup(createElement(NexusInspectionHistory, { game: view }));
assert.match(render(), /Previous answer: defense = Old defense/);
assert.match(render(), /No longer binding/);
assert.match(render(), /Current answer: defense = None/);
view.me = 'h';
assert.match(render(), /Your committed element: defense = None/);
view.me = 'e';
assert.equal(render(), '');
});
void test('Nexus controls and commitment binding never read opponent hands, plans or unprojected inspection history', () => {
const view = fixture();
const expected = nexusHtml(view);
for (const player of view.players) for (const field of ['hand', 'spice', 'traitors'])
Object.defineProperty(player, field, { get() { throw new Error(`Private ${field}`); } });
for (const field of ['plans', 'cards']) Object.defineProperty(view.battle!, field, { get() { throw new Error(`Hidden ${field}`); } });
for (const field of ['deck', 'discard', 'hands']) Object.defineProperty(view, field, { get() { throw new Error(`Source ${field}`); } });
assert.equal(nexusHtml(view), expected);
assert.ok(nexusAtreidesAction(view, 'defense'));
assert.deepEqual(battlePlanCommitments(view), {});
assert.deepEqual(bindBattlePlanCommitments(view, { type: 'battlePlan', dial: 0 }), { type: 'battlePlan', dial: 0 });
});
+448
View File
@@ -0,0 +1,448 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
viewGame,
normalizeAutomaticGame,
type Action,
type Game,
} from '../game/engine';
import {
nexusInspectionFixture,
inspectionHold,
inspectionNative,
inspectionInventory,
inspectionResidual,
} from './fixture-nexus-inspection';
import {
nexusAllow,
nexusInventory,
nexusPlayer,
nexusReload,
nexusReject,
} from './fixture-nexus-cards';
function play(g: Game, field = 'weapon', mode = 'cunning'): Game {
return applyAction(g, 'a', {
type: 'nexusAtreides',
event: g.battle!.event,
mode,
field,
});
}
function answer(g: Game, value: string | number | null): Game {
return applyAction(g, 'h', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value,
});
}
function plan(g: Game, id: string, extra: Partial<Action> = {}): Action {
return {
type: 'battlePlan',
dial: 0,
leader: nexusPlayer(g, id).leaders[0].id,
...extra,
};
}
function karama(g: Game, id: string) {
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
nexusPlayer(g, id).hand.push(card);
return card;
}
void test('Cunning spends one physical Nexus card and keeps both plan elements binding across JSON recovery', () => {
let g = inspectionNative(nexusInspectionFixture());
const weapon = inspectionHold(g, 'h', 'projectile');
const defense = inspectionHold(g, 'h', 'shield');
const first = { ...g.battle!.prescience! };
g = nexusAllow(play(g));
assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer');
g = answer(nexusReload(g), weapon.id);
assert.deepEqual(g.battle!.prescience, first);
assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]);
assert.equal(g.nexusCards!.cards!.hands.a, null);
assert.equal(
g.nexusCards!.cards!.discard.filter((card) => card === 'atreides').length,
1,
);
assert.equal(viewGame(g, 'h').battle!.ownCommitments.length, 2);
nexusReject(
g,
'h',
plan(g, 'h', { dial: 1, weapon: weapon.id }),
/unchanged|commitment/,
);
nexusReject(g, 'h', plan(g, 'h'), /unchanged|commitment/);
g = applyAction(
g,
'h',
plan(g, 'h', { weapon: weapon.id, defense: defense.id }),
);
assert.equal(g.battle!.revealed, false);
assert.deepEqual(viewGame(g, 'a').battle!.plans, {});
const restored = normalizeAutomaticGame(nexusReload(g));
assert.deepEqual(restored.battle, g.battle);
nexusInventory(restored);
});
void test('only the two combatants receive the selected Nexus answer, without exposing the rest of the target hand', () => {
let g = inspectionNative(nexusInspectionFixture());
const weapon = inspectionHold(g, 'h', 'poison');
const defense = inspectionHold(g, 'h', 'shield');
g = answer(nexusAllow(play(g)), weapon.id);
for (const id of ['a', 'h'])
assert.equal(viewGame(g, id).battle!.nexusInsights[0].value, weapon.id);
const outsider = viewGame(g, 'f');
assert.deepEqual(outsider.battle!.nexusInsights, []);
assert.deepEqual(outsider.battle!.ownCommitments, []);
assert.equal(JSON.stringify(outsider.battle).includes(weapon.id), false);
assert.equal(
JSON.stringify(viewGame(g, 'a').battle).includes(defense.id),
false,
);
assert.equal(
Object.hasOwn(outsider.battle!.nexusInspection!, 'answers'),
false,
);
nexusInventory(g);
});
void test('Cunning rejects duplicate fields, stale events, foreign ownership and replay without changing custody', () => {
let g = inspectionNative(nexusInspectionFixture());
const event = g.battle!.event;
for (const [id, action] of [
['a', { type: 'nexusAtreides', event, mode: 'cunning', field: 'dial' }],
[
'a',
{
type: 'nexusAtreides',
event: 'old-event',
mode: 'cunning',
field: 'weapon',
},
],
['h', { type: 'nexusAtreides', event, mode: 'cunning', field: 'weapon' }],
[
'a',
{ type: 'nexusAtreides', event, mode: 'secretAlly', field: 'weapon' },
],
['a', { type: 'nexusAtreides', event, mode: 'cunning', field: 'support' }],
] as Array<[string, Action]>)
nexusReject(g, id, action, /Nexus|inspection/);
g = nexusAllow(play(g));
nexusReject(
g,
'a',
{ type: 'nexusPrescienceAnswer', event, value: null },
/answer|inspection/,
);
nexusReject(
g,
'h',
{ type: 'nexusPrescienceAnswer', event: 'old-event', value: null },
/answer|inspection/,
);
g = answer(g, null);
nexusReject(
g,
'a',
{ type: 'nexusAtreides', event, mode: 'cunning', field: 'defense' },
/Nexus|inspection/,
);
nexusReject(
g,
'h',
{ type: 'nexusPrescienceAnswer', event, value: null },
/answer|inspection/,
);
nexusInventory(g);
});
void test('ordinary Karama cancels only the extra Cunning attempt and preserves the first native commitment', () => {
let g = inspectionNative(nexusInspectionFixture());
const card = karama(g, 'f');
g = play(g);
assert.equal(g.response?.kind, 'nexusPrescience');
g = applyAction(g, 'f', { type: 'card', card: card.id, mode: 'cancel' });
assert.equal(g.battle!.nexusInspection!.stage, 'canceled');
assert.deepEqual(g.battle!.nexusInspection!.answers, []);
assert.equal(g.battle!.prescience!.value, 0);
assert.equal(g.battle!.preparation, undefined);
assert.equal(viewGame(g, 'h').battle!.ownCommitments.length, 1);
nexusReject(g, 'h', plan(g, 'h', { dial: 1 }), /unchanged|commitment/);
g = applyAction(nexusReload(g), 'h', plan(g, 'h'));
nexusInventory(g);
});
void test('Secret Ally uses a genuinely absent Atreides roster and reveals only the selected element of an already sealed plan', () => {
let g = nexusInspectionFixture(true);
assert.equal(
g.players.some((p) => p.faction === 'atreides'),
false,
);
const weapon = inspectionHold(g, 'h', 'projectile');
const defense = inspectionHold(g, 'h', 'shield');
g = applyAction(
g,
'h',
plan(g, 'h', { dial: 2, weapon: weapon.id, defense: defense.id }),
);
assert.deepEqual(viewGame(g, 'a').battle!.plans, {});
g = play(g, 'weapon', 'secretAlly');
assert.equal(g.battle!.nexusInspection!.stage, 'answered');
assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]);
assert.equal(g.battle!.prescience, undefined);
const own = viewGame(g, 'a').battle!;
assert.deepEqual(own.plans, {});
assert.equal(own.nexusInsights[0].value, weapon.id);
assert.equal(JSON.stringify(own).includes(defense.id), false);
assert.deepEqual(viewGame(g, 'f').battle!.nexusInsights, []);
nexusInventory(normalizeAutomaticGame(nexusReload(g)));
});
void test('missing, rewound and rebound Nexus inspection records reject read, normalization and action', () => {
let g = inspectionNative(nexusInspectionFixture());
g = answer(nexusAllow(play(g)), null);
for (const mutate of [
(copy: Game) => {
delete copy.battle!.nexusInspection;
},
(copy: Game) => {
delete copy.battle!.nexusInspectionUsed;
},
(copy: Game) => {
copy.battle!.nexusInspection!.stage = 'answer';
},
(copy: Game) => {
copy.battle!.nexusInspection!.target = 'f';
},
(copy: Game) => {
copy.battle!.nexusInspection!.first!.value = 1;
},
]) {
const copy = nexusReload(g);
mutate(copy);
const before = nexusReload(copy);
assert.throws(() => viewGame(copy, 'a'));
assert.throws(() => normalizeAutomaticGame(copy));
assert.throws(() => applyAction(copy, 'h', plan(copy, 'h')));
assert.deepEqual(copy, before);
}
});
void test('Betrayal remains explicitly unavailable while its reaction policy is unresolved, preserving the held card and native attempt', () => {
let g = nexusInspectionFixture(false, 'f');
karama(g, 'h');
g = applyAction(g, 'a', { type: 'prescience', field: 'weapon' });
assert.equal(g.response?.kind, 'prescience');
assert.equal(Object.hasOwn(g.battle!.prescience!, 'value'), false);
const offer = viewGame(g, 'f').nexusAtreides!;
assert.equal(offer.mode, 'betrayal');
assert.match(offer.blocked!, /reaction|policy|unfinished|not.*available/i);
nexusReject(
g,
'f',
{ type: 'nexusAtreides', event: g.battle!.event, mode: 'betrayal' },
/reaction|policy|unfinished|not.*available/i,
);
assert.equal(g.nexusCards!.cards!.hands.f, 'atreides');
assert.equal(g.battle!.nexusInspection, undefined);
assert.equal(g.response?.kind, 'prescience');
nexusInventory(normalizeAutomaticGame(nexusReload(g)));
});
void test('Secret Ally weapon answer reopens after Residual kills the last usable leader and preserves inactive answer history', () => {
let g = nexusInspectionFixture(true, 'a', { richese: true });
const residual = inspectionResidual(g);
const weapon = inspectionHold(g, 'h', 'projectile');
const target = nexusPlayer(g, 'h');
target.leaders.forEach((leader, i) => {
leader.dead = i !== 0;
leader.deaths = i === 0 ? 0 : 1;
});
g = answer(play(g, 'weapon', 'secretAlly'), weapon.id);
g = applyAction(g, 'a', {
type: 'card',
card: residual,
target: 'h',
event: g.battle!.event,
});
assert.equal(
nexusPlayer(g, 'h').leaders.every((l) => l.dead),
true,
);
assert.equal(g.battle!.nexusInspection!.stage, 'answer');
assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]);
assert.equal(viewGame(g, 'a').battle!.nexusInsights[0].active, false);
assert.deepEqual(viewGame(g, 'h').battle!.ownCommitments, []);
g = answer(nexusReload(g), null);
assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id, null]);
assert.deepEqual(
viewGame(g, 'a').battle!.nexusInsights.map((i) => i.active),
[false, true],
);
assert.deepEqual(viewGame(g, 'f').battle!.nexusInsights, []);
nexusReject(
g,
'h',
plan(g, 'h', { leader: null, weapon: weapon.id }),
/leader|unchanged|commitment/i,
);
g = applyAction(g, 'h', plan(g, 'h', { leader: null }));
inspectionInventory(normalizeAutomaticGame(nexusReload(g)));
});
void test('Residual retains a Secret Ally weapon answer when a surviving leader, Cheap Hero or Ghola still permits that plan', () => {
for (const alternative of ['leader', 'hero', 'ghola'] as const) {
let g = nexusInspectionFixture(true, 'a', { richese: true });
const residual = inspectionResidual(g);
const weapon = inspectionHold(g, 'h', 'projectile');
nexusPlayer(g, 'h').leaders.forEach((leader, i) => {
leader.dead = i >= (alternative === 'leader' ? 2 : 1);
leader.deaths = leader.dead ? 1 : 0;
});
if (alternative === 'hero') inspectionHold(g, 'h', 'hero');
if (alternative === 'ghola') {
const index = g.deck.findIndex((card) => card.effect === 'ghola');
assert.ok(index >= 0);
nexusPlayer(g, 'h').hand.push(g.deck.splice(index, 1)[0]);
}
g = answer(play(g, 'weapon', 'secretAlly'), weapon.id);
g = applyAction(g, 'a', {
type: 'card',
card: residual,
target: 'h',
event: g.battle!.event,
});
assert.equal(g.battle!.nexusInspection!.stage, 'answered', alternative);
assert.deepEqual(g.battle!.nexusInspection!.answers, [weapon.id]);
assert.equal(viewGame(g, 'h').battle!.ownCommitments[0].value, weapon.id);
assert.equal(viewGame(g, 'a').battle!.nexusInsights[0].active, true);
inspectionInventory(g);
}
});
void test('an answered Secret Ally leader inspection blocks Residual before card loss or random casualty', () => {
let g = nexusInspectionFixture(true, 'a', { richese: true });
const residual = inspectionResidual(g);
const leader = nexusPlayer(g, 'h').leaders[0].id;
g = answer(play(g, 'leader', 'secretAlly'), leader);
assert.match(
viewGame(g, 'a').residualPoison!.blocked!,
/leader|inspection|Prescience/i,
);
nexusReject(
g,
'a',
{ type: 'card', card: residual, target: 'h', event: g.battle!.event },
/leader|inspection|Prescience/i,
);
inspectionInventory(g);
});
void test('the same genuine Richese No-Field marker blocks native Cunning dial inspection but allows Secret Ally dial', () => {
let native = nexusInspectionFixture(false, 'a', { noField: true });
assert.ok(native.battle!.noFieldPlayers?.includes('h'));
native = inspectionNative(native, 'weapon', null);
assert.equal(
viewGame(native, 'a').nexusAtreides!.fields.includes('dial'),
false,
);
nexusReject(
native,
'a',
{
type: 'nexusAtreides',
event: native.battle!.event,
mode: 'cunning',
field: 'dial',
},
/field|No-Field/i,
);
let secret = nexusInspectionFixture(true, 'a', { noField: true });
assert.ok(secret.battle!.noFieldPlayers?.includes('h'));
assert.equal(
viewGame(secret, 'a').nexusAtreides!.fields.includes('dial'),
true,
);
secret = answer(play(secret, 'dial', 'secretAlly'), 1);
assert.deepEqual(secret.battle!.nexusInspection!.answers, [1]);
inspectionInventory(secret);
});
void test('canceled Cunning preserves its original disclosure while Residual legitimately reopens and replaces the native answer', () => {
let g = nexusInspectionFixture(false, 'a', { richese: true });
const residual = inspectionResidual(g);
const weapon = inspectionHold(g, 'h', 'projectile');
const cancel = karama(g, 'f');
nexusPlayer(g, 'h').leaders.forEach((leader, i) => {
leader.dead = i !== 0;
leader.deaths = leader.dead ? 1 : 0;
});
g = inspectionNative(g, 'weapon', weapon.id);
g = play(g, 'defense');
g = applyAction(g, 'f', { type: 'card', card: cancel.id, mode: 'cancel' });
assert.equal(g.battle!.nexusInspection!.stage, 'canceled');
g = applyAction(g, 'a', {
type: 'card',
card: residual,
target: 'h',
event: g.battle!.event,
});
assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer');
assert.equal(Object.hasOwn(g.battle!.prescience!, 'value'), false);
assert.equal(
Object.hasOwn(g.battle!.nexusInspection!, 'firstCurrent'),
false,
);
assert.equal(g.battle!.nexusInspection!.first!.value, weapon.id);
g = applyAction(nexusReload(g), 'h', {
type: 'prescienceAnswer',
value: null,
});
assert.equal(g.battle!.nexusInspection!.stage, 'canceled');
assert.deepEqual(g.battle!.nexusInspection!.firstCurrent, { value: null });
assert.equal(g.battle!.nexusInspection!.first!.value, weapon.id);
assert.equal(g.battle!.prescience!.value, null);
inspectionInventory(normalizeAutomaticGame(nexusReload(g)));
});
void test('a pending Cunning second answer waits behind a legitimate native Residual reanswer without losing either receipt', () => {
let g = nexusInspectionFixture(false, 'a', { richese: true });
const residual = inspectionResidual(g);
const weapon = inspectionHold(g, 'h', 'projectile');
nexusPlayer(g, 'h').leaders.forEach((leader, i) => {
leader.dead = i !== 0;
leader.deaths = leader.dead ? 1 : 0;
});
g = inspectionNative(g, 'weapon', weapon.id);
g = nexusAllow(play(g, 'defense'));
assert.equal(g.battle!.nexusInspection!.stage, 'answer');
g = applyAction(g, 'a', {
type: 'card',
card: residual,
target: 'h',
event: g.battle!.event,
});
assert.equal(g.battle!.preparation?.kind, 'prescienceAnswer');
assert.equal(g.battle!.nexusInspection!.stage, 'answer');
assert.deepEqual(g.battle!.nexusInspection!.answers, []);
assert.equal(
Object.hasOwn(g.battle!.nexusInspection!, 'firstCurrent'),
false,
);
g = applyAction(nexusReload(g), 'h', {
type: 'prescienceAnswer',
value: null,
});
assert.equal(g.battle!.preparation?.kind, 'nexusPrescienceAnswer');
assert.deepEqual(g.battle!.nexusInspection!.firstCurrent, { value: null });
g = answer(nexusReload(g), null);
assert.deepEqual(g.battle!.nexusInspection!.answers, [null]);
assert.equal(g.battle!.nexusInspection!.first!.value, weapon.id);
assert.equal(viewGame(g, 'h').battle!.ownCommitments.length, 2);
inspectionInventory(normalizeAutomaticGame(nexusReload(g)));
});
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
inspectionNative,
nexusInspectionFixture,
} from './fixture-nexus-inspection';
import {
nexusAllow,
nexusInventory,
nexusPlayer,
nexusReload,
} from './fixture-nexus-cards';
function play(g: Game, mode = 'cunning', field = 'weapon'): Game {
return applyAction(g, 'a', {
type: 'nexusAtreides',
event: g.battle!.event,
mode,
field,
});
}
function answerAction(g: Game): Action {
return {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: null,
};
}
function plan(g: Game, id: string, dial = 0): Action {
return {
type: 'battlePlan',
dial,
leader: nexusPlayer(g, id).leaders[0].id,
};
}
function rejectedEverywhere(g: Game, actor: string, action: Action): void {
const before = JSON.stringify(g);
for (const seat of g.players) {
assert.throws(() => viewGame(g, seat.id), /inspection|inspected|Nexus|Prescience|Cunning/i);
assert.equal(JSON.stringify(g), before);
}
assert.throws(
() => normalizeAutomaticGame(g),
/inspection|inspected|Nexus|Prescience|Cunning/i,
);
assert.equal(JSON.stringify(g), before);
assert.throws(
() => applyAction(g, actor, action),
/inspection|inspected|Nexus|Prescience|Cunning/i,
);
assert.equal(JSON.stringify(g), before);
}
function pendingCunning(): Game {
const g = inspectionNative(nexusInspectionFixture());
const index = g.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
nexusPlayer(g, 'f').hand.push(g.deck.splice(index, 1)[0]);
const pending = play(g);
assert.equal(pending.response?.kind, 'nexusPrescience');
assert.equal(pending.battle!.nexusInspection!.stage, 'response');
nexusInventory(pending);
return pending;
}
void test('deleting the actual Cunning cancellation response cannot silently allow its answer', () => {
const pending = pendingCunning();
const corrupted = nexusReload(pending);
corrupted.response = null;
rejectedEverywhere(corrupted, 'h', answerAction(corrupted));
// Explicitly passing the real response remains a valid recoverable path.
let restored = nexusAllow(normalizeAutomaticGame(nexusReload(pending)));
restored = applyAction(restored, 'h', answerAction(restored));
assert.equal(restored.battle!.nexusInspection!.stage, 'answered');
assert.deepEqual(restored.battle!.nexusInspection!.answers, [null]);
assert.equal(restored.battle!.prescience!.value, 0);
assert.equal(
restored.nexusCards!.cards!.discard.filter((card) => card === 'atreides')
.length,
1,
);
nexusInventory(restored);
});
void test('Cunning cancellation response owner and intent remain bound on every seat read and continuation', () => {
const pending = pendingCunning();
for (const mutate of [
(g: Game) => {
g.response!.owner = 'f';
},
(g: Game) => {
g.response!.intent = 'another-battle';
},
]) {
const corrupted = nexusReload(pending);
mutate(corrupted);
rejectedEverywhere(corrupted, 'h', answerAction(corrupted));
}
});
void test('a completed Cunning inspection cannot change its first native commitment by editing the live value', () => {
let g = nexusAllow(play(inspectionNative(nexusInspectionFixture())));
g = applyAction(g, 'h', answerAction(g));
assert.equal(g.battle!.nexusInspection!.first!.value, 0);
assert.equal(g.battle!.prescience!.value, 0);
const corrupted = nexusReload(g);
corrupted.battle!.prescience!.value = 1;
rejectedEverywhere(corrupted, 'h', plan(corrupted, 'h', 1));
const restored = applyAction(
normalizeAutomaticGame(nexusReload(g)),
'h',
plan(g, 'h'),
);
assert.equal(restored.battle!.plans.h.dial, 0);
assert.equal(viewGame(restored, 'h').battle!.ownCommitments.length, 2);
nexusInventory(restored);
});
void test('a sealed target plan cannot diverge from the real Secret Ally answer before the opponent seals', () => {
let g = nexusInspectionFixture(true);
g = applyAction(g, 'h', plan(g, 'h'));
g = play(g, 'secretAlly', 'dial');
assert.deepEqual(g.battle!.nexusInspection!.answers, [0]);
assert.equal(g.battle!.revealed, false);
assert.deepEqual(viewGame(g, 'a').battle!.plans, {});
assert.deepEqual(viewGame(g, 'f').battle!.nexusInsights, []);
const corrupted = nexusReload(g);
corrupted.battle!.plans.h.dial = 1;
rejectedEverywhere(corrupted, 'a', plan(corrupted, 'a'));
const restored = applyAction(
normalizeAutomaticGame(nexusReload(g)),
'a',
plan(g, 'a'),
);
assert.equal(restored.battle!.revealed, true);
assert.equal(restored.battle!.plans.h.dial, 0);
for (const id of ['a', 'h'])
assert.equal(viewGame(restored, id).battle!.nexusInsights[0].value, 0);
nexusInventory(restored);
});
+113
View File
@@ -0,0 +1,113 @@
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 { nexusInspectionFixture, inspectionNative, inspectionHold } from './fixture-nexus-inspection';
import { nexusInventory, nexusPlayer } from './fixture-nexus-cards';
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 = inspectionNative(nexusInspectionFixture())) {
const store = unitStore();
const made = await store.rooms.createRoom('Inspection SQL', 'fremen', false, []);
const code = made.view.code, tokens = [made.token];
for (const faction of ['atreides', 'harkonnen'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const ids = ['f', 'a', 'h'];
// The game was initialized with its final identities. Bind disposable test
// sessions to those seats; never rewrite identifiers inside signed histories.
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 play = (g: Game, field: string): Action => ({ type: 'nexusAtreides', event: g.battle!.event, mode: 'cunning', field });
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 view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(g, f.ids[i]));
if (i === 0) assert.deepEqual(view.battle!.nexusInsights, []);
}
await rooms.continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
nexusInventory(g);
}
async function race(f: Fixture, inputs: { seat: number; action: Action }[]) {
let arrivals = 0; let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++arrivals === inputs.length) release(); await gate; };
const results = await Promise.allSettled(inputs.map(({seat, action}) =>
f.restart().act(f.code, f.auths[seat], 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]);
return f.rooms.readRoom(f.code);
}
void test('Nexus inspection SQL competing second-field declarations consume exactly one card and survive module reload', async () => {
const f = await fixture();
try {
const g = await race(f, [{seat:1,action:play(f.initial,'weapon')},{seat:1,action:play(f.initial,'defense')}]);
assert.equal(g.version, f.initial.version + 1);
assert.equal(g.battle!.nexusInspection!.stage, 'answer');
assert.deepEqual(g.battle!.prescience, f.initial.battle!.prescience);
assert.equal(g.nexusCards!.cards!.discard.filter(c => c === 'atreides').length, 1);
const before = row(f);
await assert.rejects(f.rooms.act(f.code, f.auths[1], g.version, play(g,'leader'),clock));
assert.deepEqual(row(f),before);
await restored(f,g);
} finally { f.sqlite.close(); }
});
void test('Nexus inspection SQL lost answer response restores both commitments and rejects stale or duplicate disclosures', async () => {
let initial = inspectionNative(nexusInspectionFixture());
const weapon = inspectionHold(initial,'h','projectile');
initial = applyAction(initial,'a',play(initial,'weapon'));
const f = await fixture(initial);
try {
const action: Action = {type:'nexusPrescienceAnswer',event:initial.battle!.event,value:weapon.id};
await f.rooms.act(f.code,f.auths[2],initial.version,action,clock);
const g = await f.restart().readRoom(f.code), before = row(f);
assert.deepEqual(g.battle!.nexusInspection!.answers,[weapon.id]);
assert.equal(viewGame(g,'h').battle!.ownCommitments.length,2);
await assert.rejects(f.rooms.act(f.code,f.auths[2],initial.version,action,clock));
await assert.rejects(f.rooms.act(f.code,f.auths[2],g.version,action,clock));
assert.deepEqual(row(f),before);
await restored(f,g);
const damaged = structuredClone(g); damaged.battle!.nexusInspection!.answers[0] = null;
f.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(damaged), f.code);
f.writes.length = 0;
await assert.rejects(f.restart().readSeatView(f.code,f.auths[2]));
await assert.rejects(f.restart().act(f.code,f.auths[2],g.version,action,clock));
// No automatic continuation is scheduled at this owned decision. The
// scheduler leaves it untouched; authenticated reads/actions reject it.
await f.restart().continueRoomAutomatic(f.code,clock);
assert.equal(f.writes.length,0);
} finally { f.sqlite.close(); }
});
void test('Nexus inspection SQL concurrent Karamas cancel one extra attempt and charge only its accepted responder', async () => {
let initial = inspectionNative(nexusInspectionFixture());
const cards = ['f','h'].map(id => {
const index = initial.deck.findIndex(c => c.effect === 'karama'); assert.ok(index >= 0);
const card = initial.deck.splice(index,1)[0]; nexusPlayer(initial,id).hand.push(card); return card;
});
initial = applyAction(initial,'a',play(initial,'weapon'));
const f = await fixture(initial);
try {
const g = await race(f,cards.map((card,i) => ({seat:i===0?0:2,action:{type:'card',card:card.id,mode:'cancel'}})));
assert.equal(g.battle!.nexusInspection!.stage,'canceled');
assert.equal(g.battle!.prescience!.value,0);
assert.equal(cards.filter(card => g.players.some(p => p.hand.some(c => c.id === card.id))).length,1);
assert.deepEqual(g.battle!.nexusInspection!.answers,[]);
await restored(f,g);
} finally { f.sqlite.close(); }
});
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
nexusInspectionFixture,
inspectionNative,
inspectionInventory,
} from './fixture-nexus-inspection';
import { nexusAllow, nexusPlayer, nexusReload } from './fixture-nexus-cards';
const residual = 'richese-residual-poison';
function inventory(g: Game): string[] {
return [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...g.players.flatMap((p) => p.hand),
]
.map((c) => c.id)
.sort();
}
function answeredCunning(): Game {
let g = inspectionNative(
nexusInspectionFixture(false, 'a', { richese: true }),
'dial',
0,
);
// Conserved acquisition seam: move the canonical Residual from the cache
// initialized by the genuine Richese setup. No purchase is claimed here.
assert.ok(g.richeseCache);
const index = g.richeseCache.findIndex((c) => c.id === residual);
nexusPlayer(g, 'a').hand.push(g.richeseCache.splice(index, 1)[0]);
assert.equal(viewGame(g, 'a').residualPoison!.blocked, null);
g = nexusAllow(
applyAction(g, 'a', {
type: 'nexusAtreides',
event: g.battle!.event,
mode: 'cunning',
field: 'weapon',
}),
);
g = applyAction(g, 'h', {
type: 'nexusPrescienceAnswer',
event: g.battle!.event,
value: null,
});
assert.equal(g.battle!.nexusInspection!.stage, 'answered');
assert.equal(g.battle!.nexusInspection!.mode, 'cunning');
inspectionInventory(g);
return g;
}
void test('answered Cunning publicly blocks Residual independently of hidden funding or Ghola, before RNG or mutation', (t) => {
const original = answeredCunning();
assert.deepEqual(
inventory(original),
[...baseDeck(), ...richeseCards()].map((c) => c.id).sort(),
);
const reasons: string[] = [];
for (const hasGhola of [false, true]) {
const g = nexusReload(original),
target = nexusPlayer(g, 'h');
const index = g.deck.findIndex((c) =>
hasGhola ? c.effect === 'ghola' : c.kind === 'projectile',
);
assert.ok(index >= 0);
target.hand.push(g.deck.splice(index, 1)[0]);
target.spice = hasGhola ? 0 : 20;
const before = nexusReload(g),
beforeCards = inventory(g);
const reason = viewGame(g, 'a').residualPoison!.blocked;
assert.ok(reason);
assert.match(reason, /two inspected elements.*ruling/i);
reasons.push(reason);
for (const p of g.players) assert.doesNotThrow(() => viewGame(g, p.id));
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), g);
let randomCalls = 0;
const random = t.mock.method(crypto, 'getRandomValues', () => {
randomCalls++;
throw Error('Rejected action must not draw a victim');
});
try {
assert.throws(
() =>
applyAction(g, 'a', {
type: 'card',
card: residual,
target: 'h',
event: g.battle!.event,
}),
/two inspected elements.*ruling/i,
);
} finally {
random.mock.restore();
}
assert.equal(randomCalls, 0);
assert.deepEqual(g, before);
assert.deepEqual(inventory(g), beforeCards);
assert.equal(
nexusPlayer(g, 'a').hand.filter((c) => c.id === residual).length,
1,
);
assert.equal(g.battle!.prescience!.value, 0);
assert.deepEqual(g.battle!.nexusInspection!.answers, [null]);
}
assert.equal(reasons[0], reasons[1]);
});