Implement Atreides Nexus inspections with durable battle commitments
This commit is contained in:
1 parent
d2a1d63517
commit
d820c8a414
25 files changed
+3171
-182
No files matched your search
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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')};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in new issue
Block a user