Implement Harkonnen Nexus exchanges with durable traitor declarations
This commit is contained in:
1 parent
d820c8a414
commit
d5830d260c
20 files changed
+3158
-10
No files matched your search
@@ -0,0 +1,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeNexusGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { botActions } from '../game/bots';
|
||||
import { createAuditorLeader, leaders, treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { drawNexusCard, validateNexusCards } from '../game/nexus-cards';
|
||||
import { validateNexusTraitorSnapshot } from '../game/nexus-traitor-exchange';
|
||||
import {
|
||||
enterNexusSpice,
|
||||
finishNexusSpice,
|
||||
orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
export function nexusTraitorFixture(
|
||||
options: {
|
||||
ownerFaction?: FactionId;
|
||||
opponentFaction?: FactionId;
|
||||
advanced?: boolean;
|
||||
ix?: boolean;
|
||||
seatIds?: [string, string, string];
|
||||
phase?: number;
|
||||
} = {},
|
||||
): Game {
|
||||
const [p, q, r] = options.seatIds ?? ['p', 'q', 'r'];
|
||||
let g = createGame(
|
||||
'TRAITORAUDIT',
|
||||
newPlayer(p, 'Owner', 'harkonnen'),
|
||||
options.advanced ?? false,
|
||||
options.ix ? ['ix'] : [],
|
||||
);
|
||||
joinGame(g, newPlayer(q, 'Opponent', 'guild'));
|
||||
joinGame(g, newPlayer(r, 'Observer', 'fremen'));
|
||||
// Final faction is fixed before the genuine component/setup initializer.
|
||||
g.players[0] = newPlayer(p, 'Owner', options.ownerFaction ?? 'harkonnen');
|
||||
if (options.opponentFaction)
|
||||
g.players[1] = newPlayer(q, 'Opponent', options.opponentFaction);
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
g = initializeNexusGameForAudit(g);
|
||||
for (let i = 0; g.status === 'setup' && i < 80; i++) {
|
||||
let next: Game | undefined;
|
||||
for (const player of g.players) {
|
||||
const v = viewGame(g, player.id);
|
||||
v.players.find((seat) => seat.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, player.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(next, `Setup stalled at ${g.setupStage}/${g.decision?.kind}`);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
for (const player of g.players) g.deck.push(...player.hand.splice(0));
|
||||
if (
|
||||
options.ix ||
|
||||
(g.advanced && g.players.some((p) => p.faction === 'choam'))
|
||||
) {
|
||||
// Resolve actual CHOAM markets and Ix phase openings before advancing.
|
||||
for (let i = 0; g.phase === 0 && i < 30; 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,
|
||||
`Storm stalled at ${g.decision?.kind}/${g.response?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.phase, 1);
|
||||
} else g = enterNexusSpice(g);
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
if (
|
||||
options.ix ||
|
||||
(g.advanced && g.players.some((p) => p.faction === 'choam'))
|
||||
) {
|
||||
for (let i = 0; g.phase === 1 && i < 40; 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,
|
||||
`Spice phase stalled at ${g.decision?.kind}/${g.response?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.phase, 2);
|
||||
} else g = finishNexusSpice(g);
|
||||
Object.assign(g, {
|
||||
phase: options.phase ?? 8,
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
active: p,
|
||||
storm: 18,
|
||||
});
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.deck = [
|
||||
'harkonnen',
|
||||
...cards.deck.filter((card) => card !== 'harkonnen'),
|
||||
];
|
||||
g.nexusCards!.cards = drawNexusCard(cards, p, g.players, () => 0);
|
||||
nexusTraitorInventory(g);
|
||||
return g;
|
||||
}
|
||||
export function nexusTraitorInventory(g: Game): void {
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
const universe = traitorDeck(
|
||||
g.players.map((p) => ({
|
||||
leaders: [
|
||||
...leaders(p.faction),
|
||||
...(g.advanced && p.faction === 'choam' ? [createAuditorLeader()] : []),
|
||||
],
|
||||
})),
|
||||
g.expansions.includes('ix'),
|
||||
);
|
||||
validateNexusTraitorSnapshot(
|
||||
{
|
||||
reserve: g.traitorReserve!,
|
||||
players: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
traitors: [...p.traitors],
|
||||
...(p.faceDancers
|
||||
? { faceDancers: structuredClone(p.faceDancers) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
universe,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
|
||||
.map((c) => c.id)
|
||||
.sort(),
|
||||
treacheryDeck(g.expansions)
|
||||
.map((c) => c.id)
|
||||
.sort(),
|
||||
);
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves +
|
||||
p.tanks +
|
||||
Object.values(p.forces).reduce((sum, n) => sum + n, 0),
|
||||
20,
|
||||
);
|
||||
}
|
||||
export function nexusTraitorDraw(g: Game, id = g.players[0].id): Game {
|
||||
const offer = viewGame(g, id).nexusTraitors!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
return applyAction(g, id, {
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: offer.mode,
|
||||
});
|
||||
}
|
||||
export function nexusTraitorReturn(
|
||||
g: Game,
|
||||
cards: string[],
|
||||
id = g.players[0].id,
|
||||
): Game {
|
||||
return applyAction(g, id, {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: viewGame(g, id).nexusTraitors!.pending!.event,
|
||||
cards,
|
||||
});
|
||||
}
|
||||
/** Preserve every physical traitor identity while staging a known held traitor. */
|
||||
export function holdNexusTraitor(g: Game, id: string, card: string): void {
|
||||
const owner = g.players.find((p) => p.id === id)!;
|
||||
if (owner.traitors.includes(card)) return;
|
||||
const replace = owner.traitors[0];
|
||||
assert.ok(replace);
|
||||
const reserveIndex = g.traitorReserve!.indexOf(card);
|
||||
if (reserveIndex >= 0) g.traitorReserve![reserveIndex] = replace;
|
||||
else {
|
||||
const other = g.players.find((p) => p.traitors.includes(card));
|
||||
assert.ok(other);
|
||||
other.traitors[other.traitors.indexOf(card)] = replace;
|
||||
}
|
||||
owner.traitors[0] = card;
|
||||
}
|
||||
export function nexusTraitorBattle(g: Game, reveal = true): Game {
|
||||
const [owner, target] = g.players;
|
||||
Object.assign(g, {
|
||||
phase: 6,
|
||||
active: owner.id,
|
||||
order: g.players.map((p) => p.id),
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const p of g.players) {
|
||||
p.forces = {};
|
||||
p.reserves = 20;
|
||||
p.tanks = 0;
|
||||
}
|
||||
for (const p of [owner, target]) {
|
||||
p.forces = { 'pasty_mesa:5': 5 };
|
||||
p.reserves = 15;
|
||||
}
|
||||
g = applyAction(g, owner.id, {
|
||||
type: 'chooseBattle',
|
||||
territory: 'pasty_mesa',
|
||||
target: target.id,
|
||||
});
|
||||
for (const id of [owner.id, target.id])
|
||||
if (g.battle!.preLeader && !g.battle!.preLeader.closed)
|
||||
g = applyAction(g, id, {
|
||||
type: 'battlePreparationReady',
|
||||
event: g.battle!.event,
|
||||
});
|
||||
if (!reveal) return g;
|
||||
while (g.battle!.preparation)
|
||||
g = applyAction(g, g.battle!.preparation.owner, {
|
||||
type: 'declineBattlePower',
|
||||
});
|
||||
for (const id of [owner.id, target.id])
|
||||
g = applyAction(g, id, {
|
||||
type: 'battlePlan',
|
||||
dial: 0,
|
||||
leader: g.players.find((p) => p.id === id)!.leaders[0].id,
|
||||
});
|
||||
assert.equal(g.battle!.revealed, true);
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
nexusTraitorBotActions,
|
||||
nexusTraitorDrawAction,
|
||||
nexusTraitorReturnAction,
|
||||
} from '../game/nexus-traitor-options';
|
||||
import {
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
|
||||
void test('all four profiles complete actual Cunning and Face Dancer exchanges with conserved custody', () => {
|
||||
for (const ownerFaction of ['harkonnen', 'tleilaxu'] as const) {
|
||||
const initial = nexusTraitorFixture({ ownerFaction });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(initial, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
const before = structuredClone(v);
|
||||
const begin = botActions(v);
|
||||
assert.equal(begin.length, 1);
|
||||
assert.equal(begin[0].type, 'nexusTraitorDraw');
|
||||
assert.deepEqual(v, before);
|
||||
const drawn = applyAction(initial, 'p', begin[0]);
|
||||
const pending = viewGame(drawn, 'p');
|
||||
pending.players[0].bot = difficulty;
|
||||
const returns = botActions(pending);
|
||||
assert.equal(returns.length, 1);
|
||||
assert.equal(returns[0].type, 'nexusTraitorReturn');
|
||||
const cards = returns[0].cards as string[];
|
||||
assert.equal(cards.length, ownerFaction === 'harkonnen' ? 1 : 2);
|
||||
assert.equal(new Set(cards).size, cards.length);
|
||||
assert.deepEqual(
|
||||
returns[0],
|
||||
nexusTraitorReturnAction(
|
||||
pending,
|
||||
pending.nexusTraitors!.pending!.event,
|
||||
cards,
|
||||
),
|
||||
);
|
||||
const settled = applyAction(drawn, 'p', returns[0]);
|
||||
nexusTraitorInventory(settled);
|
||||
assert.equal(viewGame(settled, 'p').nexusTraitors!.pending, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('pending exchange outranks preserved controls and automatic flags while Truth outranks exchange and Nullentropy', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = viewGame(g, 'p');
|
||||
v.players[0].bot = difficulty;
|
||||
v.automaticContinuationPending = true;
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
assert.equal(botActions(v)[0]?.type, 'nexusTraitorReturn');
|
||||
v.truthtrance = {
|
||||
stage: 'priority',
|
||||
queue: [],
|
||||
passed: [],
|
||||
question: null,
|
||||
};
|
||||
assert.deepEqual(botActions(v), [{ type: 'truthPass' }]);
|
||||
v.truthtrance.passed = ['p'];
|
||||
assert.deepEqual(botActions(v), []);
|
||||
}
|
||||
for (const id of ['q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
v.players.find((p) => p.id === id)!.bot = 'Brutal';
|
||||
Object.defineProperty(v.nexusTraitors!.pending!, 'choices', {
|
||||
get() {
|
||||
throw new Error('Private exchange choices read');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(botActions(v), []);
|
||||
}
|
||||
});
|
||||
|
||||
/** Ranking inputs here are explicit authorized projections. Actual exchange
|
||||
* execution/custody is covered above; these staged views isolate preferences. */
|
||||
function rankingFixture(): GameView {
|
||||
const g = nexusTraitorFixture({ phase: 6 });
|
||||
g.battle = {
|
||||
event: 'ranking-battle',
|
||||
territory: 'arrakeen',
|
||||
attacker: 'p',
|
||||
defender: 'q',
|
||||
prepared: true,
|
||||
plans: {},
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
const v = viewGame(g, 'p');
|
||||
v.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {
|
||||
event: 'ranking-exchange',
|
||||
owner: 'p',
|
||||
mode: 'cunning',
|
||||
count: 1,
|
||||
choices: [
|
||||
v.players[0].leaders[0].id,
|
||||
v.players[1].leaders[0].id,
|
||||
v.players[2].leaders[0].id,
|
||||
].map((id) => ({ id, revealed: false, drawn: false })),
|
||||
},
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
void test('ranking reads only authorized choices and public leaders, preserving a revealed opposing traitor', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = rankingFixture();
|
||||
v.players[0].bot = difficulty;
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const own = pending.choices[0].id;
|
||||
const opposing = pending.choices[1].id;
|
||||
const other = pending.choices[2].id;
|
||||
for (const player of v.players.slice(1))
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Object.defineProperty(player, key, {
|
||||
get() {
|
||||
throw new Error(`Read opponent ${key}`);
|
||||
},
|
||||
});
|
||||
const b = v.battle!;
|
||||
Object.defineProperty(b, 'plans', {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error('Read unrevealed plans');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [own] },
|
||||
]);
|
||||
// A public dead leader is expendable when no own identity is offered.
|
||||
pending.choices = pending.choices.slice(1);
|
||||
v.players[2].leaders[0].dead = true;
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [other] },
|
||||
]);
|
||||
Object.defineProperty(b, 'plans', {
|
||||
value: { q: { leader: other, dial: 0, weapon: null, defense: null } },
|
||||
writable: true,
|
||||
});
|
||||
b.revealed = true;
|
||||
assert.deepEqual(botActions(v), [
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [opposing] },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('optional bot Cunning waits for an unreplied public traitor call or Mentat; authoritative blocks remain binding', () => {
|
||||
const v = rankingFixture();
|
||||
v.nexusTraitors = {
|
||||
offer: { event: 'offer', mode: 'cunning', draw: 1, blocked: null },
|
||||
pending: null,
|
||||
};
|
||||
const b = v.battle!;
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
b.revealed = true;
|
||||
b.traitorVoters = ['p', 'q'];
|
||||
b.traitorSubmitted = [];
|
||||
assert.deepEqual(nexusTraitorBotActions(v), [
|
||||
{ type: 'nexusTraitorDraw', event: 'offer', mode: 'cunning' },
|
||||
]);
|
||||
b.traitorSubmitted = ['p'];
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
v.phase = 8;
|
||||
assert.equal(nexusTraitorBotActions(v)[0]?.type, 'nexusTraitorDraw');
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
assert.ok(nexusTraitorDrawAction(v, 'offer', 'cunning'));
|
||||
v.decision = null;
|
||||
v.nexusTraitors.offer!.blocked = 'Exchange unavailable.';
|
||||
assert.deepEqual(nexusTraitorBotActions(v), []);
|
||||
const secret = viewGame(
|
||||
nexusTraitorFixture({ ownerFaction: 'tleilaxu' }),
|
||||
'p',
|
||||
);
|
||||
assert.equal(nexusTraitorBotActions(secret)[0]?.type, 'nexusTraitorDraw');
|
||||
secret.phase = 7;
|
||||
assert.deepEqual(nexusTraitorBotActions(secret), []);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorBattle,
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorReturn,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
||||
|
||||
function hold(g: Game, owner: string, effect: string): string {
|
||||
const source = g.richeseCache?.some((card) => card.effect === effect)
|
||||
? g.richeseCache
|
||||
: g.deck;
|
||||
const index = source.findIndex((card) => card.effect === effect);
|
||||
assert.ok(index >= 0, `Missing physical ${effect}`);
|
||||
const card = source.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === owner)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
function returnAction(g: Game): Action {
|
||||
const exchange = g.nexusTraitorExchanges!.at(-1)!;
|
||||
return {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: exchange.event,
|
||||
cards: [...exchange.drawn],
|
||||
};
|
||||
}
|
||||
function rejectEverywhere(g: Game): void {
|
||||
const before = JSON.stringify(g);
|
||||
for (const player of g.players) {
|
||||
assert.throws(() => viewGame(g, player.id));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
assert.throws(() => applyAction(g, 'p', returnAction(g)));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
function pendingResponse(): Game {
|
||||
let g = nexusTraitorFixture({ opponentFaction: 'atreides' });
|
||||
hold(g, 'r', 'karama');
|
||||
g = nexusTraitorBattle(g, false);
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
assert.equal(g.response?.kind, 'prescience');
|
||||
return g;
|
||||
}
|
||||
function beginBox(): Game {
|
||||
let g = nexusTraitorFixture({ opponentFaction: 'richese' });
|
||||
const box = hold(g, 'q', 'nullentropyBox');
|
||||
for (const kind of ['shield', 'projectile']) {
|
||||
const index = g.deck.findIndex((card) => card.kind === kind);
|
||||
assert.ok(index >= 0);
|
||||
g.discard.push(g.deck.splice(index, 1)[0]);
|
||||
}
|
||||
g.players[1].spice = 10;
|
||||
g = applyAction(g, 'q', { type: 'card', card: box });
|
||||
assert.equal(g.decision?.kind, 'nullentropy');
|
||||
assert.ok(g.pendingNullentropy);
|
||||
return g;
|
||||
}
|
||||
function cards(g: Game): string[] {
|
||||
return [
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...(g.richeseCache ?? []),
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort();
|
||||
}
|
||||
function truthPriority(g: Game): Game {
|
||||
for (let i = 0; g.truthtrance?.stage === 'priority' && i < 12; i++) {
|
||||
const owner = g.players.find((p) => !g.truthtrance!.passed.includes(p.id));
|
||||
assert.ok(owner);
|
||||
g = applyAction(g, owner.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(g.truthtrance?.stage, 'ask');
|
||||
return g;
|
||||
}
|
||||
|
||||
void test('Cunning cannot lose or rebind its real suspended Prescience cancellation response', () => {
|
||||
const original = pendingResponse();
|
||||
const pending = nexusTraitorDraw(original);
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
g.response = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.response!.owner = 'r';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.response!.passed.push('r');
|
||||
},
|
||||
(g: Game) => {
|
||||
g.battle!.preparation!.owner = 'q';
|
||||
},
|
||||
]) {
|
||||
const changed = nexusReload(pending);
|
||||
mutate(changed);
|
||||
rejectEverywhere(changed);
|
||||
}
|
||||
let g = nexusTraitorReturn(
|
||||
normalizeAutomaticGame(nexusReload(pending)),
|
||||
pending.nexusTraitorExchanges![0].drawn,
|
||||
);
|
||||
assert.deepEqual(g.response, original.response);
|
||||
assert.deepEqual(g.battle, original.battle);
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 });
|
||||
assert.equal(g.battle!.prescience!.value, 0);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
|
||||
void test('a pending Nexus exchange requires its original parent and pending markers', () => {
|
||||
const pending = nexusTraitorDraw(pendingResponse());
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorParent;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorParent = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorParent!.event = 'another-exchange';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorParent!.signature = 'changed';
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorPending;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorParent;
|
||||
delete g.nexusTraitorPending;
|
||||
},
|
||||
]) {
|
||||
const changed = nexusReload(pending);
|
||||
mutate(changed);
|
||||
rejectEverywhere(changed);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real Nullentropy search remains paid once and intact through a Nexus exchange and JSON reload', () => {
|
||||
const original = beginBox();
|
||||
const inventory = cards(original);
|
||||
const paid = original.players[1].spice;
|
||||
const pending = nexusTraitorDraw(original);
|
||||
const beforeEarlyChoice = JSON.stringify(pending);
|
||||
assert.throws(
|
||||
() =>
|
||||
applyAction(pending, 'q', {
|
||||
type: 'decision',
|
||||
event: pending.pendingNullentropy!.event,
|
||||
card: pending.discard[0].id,
|
||||
}),
|
||||
/Nexus|return/i,
|
||||
);
|
||||
assert.equal(JSON.stringify(pending), beforeEarlyChoice);
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
g.decision = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.decision!.player = 'r';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingNullentropy = null;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingNullentropy!.resume.response = {
|
||||
kind: 'guildIncome',
|
||||
owner: 'p',
|
||||
amount: 3,
|
||||
passed: [],
|
||||
};
|
||||
},
|
||||
]) {
|
||||
const changed = nexusReload(pending);
|
||||
mutate(changed);
|
||||
rejectEverywhere(changed);
|
||||
}
|
||||
let g = nexusTraitorReturn(
|
||||
normalizeAutomaticGame(nexusReload(pending)),
|
||||
pending.nexusTraitorExchanges![0].drawn,
|
||||
);
|
||||
assert.deepEqual(
|
||||
g.pendingNullentropy,
|
||||
nexusReload(original).pendingNullentropy,
|
||||
);
|
||||
assert.deepEqual(g.decision, original.decision);
|
||||
assert.equal(g.players[1].spice, paid);
|
||||
assert.equal(paid, 8);
|
||||
const selected = g.discard[0].id;
|
||||
g = applyAction(g, 'q', {
|
||||
type: 'decision',
|
||||
event: g.pendingNullentropy!.event,
|
||||
card: selected,
|
||||
});
|
||||
assert.equal(g.pendingNullentropy, null);
|
||||
assert.equal(g.players[1].spice, paid);
|
||||
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
||||
assert.equal(
|
||||
g.discard.filter((card) => card.effect === 'nullentropyBox').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(cards(g), inventory);
|
||||
});
|
||||
|
||||
void test('a real Truthtrance overlay restores the exchange and preceding response without consuming either twice', () => {
|
||||
const initial = pendingResponse();
|
||||
const truth = hold(initial, 'r', 'truthtrance');
|
||||
const pending = nexusTraitorDraw(initial);
|
||||
const originalBattle = structuredClone(pending.battle);
|
||||
const originalResponse = structuredClone(pending.response);
|
||||
const originalExchange = structuredClone(pending.nexusTraitorExchanges);
|
||||
const originalParent = structuredClone(pending.nexusTraitorParent);
|
||||
let g = applyAction(pending, 'r', { type: 'card', card: truth });
|
||||
g = truthPriority(normalizeAutomaticGame(nexusReload(g)));
|
||||
g = applyAction(g, 'r', {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: 'p',
|
||||
fact: { kind: 'traitor', leader: g.players[0].traitors[0] },
|
||||
},
|
||||
});
|
||||
g = applyAction(nexusReload(g), 'p', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(g.truthtrance, null);
|
||||
assert.equal(g.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(g.response, originalResponse);
|
||||
assert.deepEqual(g.battle, originalBattle);
|
||||
assert.deepEqual(g.nexusTraitorExchanges, originalExchange);
|
||||
assert.deepEqual(g.nexusTraitorParent, originalParent);
|
||||
assert.equal(g.discard.filter((card) => card.id === truth).length, 1);
|
||||
assert.equal(viewGame(g, 'r').nexusTraitors!.pending!.choices.length, 0);
|
||||
g = nexusTraitorReturn(
|
||||
normalizeAutomaticGame(nexusReload(g)),
|
||||
g.nexusTraitorExchanges![0].drawn,
|
||||
);
|
||||
assert.deepEqual(g.response, originalResponse);
|
||||
assert.equal(g.nexusTraitorParent, null);
|
||||
assert.equal(g.nexusTraitorExchanges![0].stage, 'complete');
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { viewGame, type GameView } from '../game/engine';
|
||||
import {
|
||||
nexusTraitorDrawAction,
|
||||
nexusTraitorReturnAction,
|
||||
} from '../game/nexus-traitor-options';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorDraw,
|
||||
} from './fixture-nexus-traitors';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
const { NexusTraitors } = await import('../components/nexus-traitors');
|
||||
aliases.deregister();
|
||||
|
||||
function html(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(NexusTraitors, { game, busy, act() {} }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Nexus draw control uses the private current offer and keeps human Cunning available over preserved controls', () => {
|
||||
const v = viewGame(nexusTraitorFixture(), 'p');
|
||||
const offer = v.nexusTraitors!.offer!;
|
||||
const expected = {
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: 'cunning',
|
||||
};
|
||||
assert.deepEqual(nexusTraitorDrawAction(v, offer.event, 'cunning'), expected);
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
v.automaticContinuationPending = true;
|
||||
assert.deepEqual(nexusTraitorDrawAction(v, offer.event, 'cunning'), expected);
|
||||
assert.match(html(v), /Draw 1 Traitor card/);
|
||||
assert.match(html(v, true), /disabled/);
|
||||
assert.equal(nexusTraitorDrawAction(v, 'stale', 'cunning'), null);
|
||||
assert.equal(nexusTraitorDrawAction(v, offer.event, 'secretAlly'), null);
|
||||
offer.blocked = 'Finish the current Nexus card draw.';
|
||||
assert.equal(nexusTraitorDrawAction(v, offer.event, 'cunning'), null);
|
||||
assert.match(html(v), /Finish the current Nexus card draw/);
|
||||
offer.blocked = null;
|
||||
v.nexusCards!.card = 'atreides';
|
||||
assert.equal(nexusTraitorDrawAction(v, offer.event, 'cunning'), null);
|
||||
});
|
||||
|
||||
void test('owner exchange renders real identities and explicit choice without automatic confirmation', () => {
|
||||
const v = viewGame(nexusTraitorDraw(nexusTraitorFixture()), 'p');
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const rendered = html(v);
|
||||
assert.equal(
|
||||
(rendered.match(/type="checkbox"/g) ?? []).length,
|
||||
pending.choices.length,
|
||||
);
|
||||
assert.doesNotMatch(rendered, /checked=""/);
|
||||
assert.match(rendered, /0 of 1 selected/);
|
||||
assert.match(rendered, /previous game action is paused/);
|
||||
assert.match(rendered, /disabled=""[^>]*>Return selected card/);
|
||||
for (const choice of pending.choices) {
|
||||
const identity = v.allLeaders.find((leader) => leader.id === choice.id)!;
|
||||
assert.ok(rendered.includes(identity.name));
|
||||
assert.ok(rendered.includes(`Inspect traitor: ${identity.name}`));
|
||||
assert.ok(rendered.includes(`Strength ${identity.strength}`));
|
||||
}
|
||||
const drawn = pending.choices.find((choice) => choice.drawn)!.id;
|
||||
assert.deepEqual(nexusTraitorReturnAction(v, pending.event, [drawn]), {
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [drawn],
|
||||
});
|
||||
});
|
||||
|
||||
void test('Face Dancer exchange requires exactly two projected choices and excludes previously revealed dancers', () => {
|
||||
const g = nexusTraitorFixture({ ownerFaction: 'tleilaxu' });
|
||||
g.players[0].faceDancers![0].revealed = true;
|
||||
const excluded = g.players[0].faceDancers![0].leader;
|
||||
const v = viewGame(nexusTraitorDraw(g), 'p');
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
const selected = pending.choices.slice(0, 2).map((choice) => choice.id);
|
||||
assert.equal(pending.count, 2);
|
||||
assert.ok(!pending.choices.some((choice) => choice.id === excluded));
|
||||
assert.match(html(v), /Inspect Face Dancer:/);
|
||||
assert.match(html(v), /Previously revealed Face Dancers are not available/);
|
||||
assert.ok(nexusTraitorReturnAction(v, pending.event, selected));
|
||||
for (const cards of [
|
||||
[],
|
||||
selected.slice(0, 1),
|
||||
[selected[0], selected[0]],
|
||||
[selected[0], excluded],
|
||||
[...selected, excluded],
|
||||
])
|
||||
assert.equal(nexusTraitorReturnAction(v, pending.event, cards), null);
|
||||
assert.equal(nexusTraitorReturnAction(v, 'old-exchange', selected), null);
|
||||
v.decision = { kind: 'nullentropy', player: 'p' };
|
||||
v.automaticContinuationPending = true;
|
||||
assert.ok(nexusTraitorReturnAction(v, pending.event, selected));
|
||||
v.truthtrance = { stage: 'priority', queue: [], passed: [], question: null };
|
||||
assert.equal(nexusTraitorReturnAction(v, pending.event, selected), null);
|
||||
assert.match(html(v), /Finish Truthtrance before returning/);
|
||||
});
|
||||
|
||||
void test('observer exchange controls never read private card choices', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const id of ['q', 'r']) {
|
||||
const v = viewGame(g, id);
|
||||
const pending = v.nexusTraitors!.pending!;
|
||||
assert.deepEqual(pending.choices, []);
|
||||
Object.defineProperty(pending, 'choices', {
|
||||
get() {
|
||||
throw new Error('Read another seat’s choices');
|
||||
},
|
||||
});
|
||||
const rendered = html(v);
|
||||
assert.match(rendered, /Waiting for Owner to return 1 card privately/);
|
||||
assert.doesNotMatch(
|
||||
rendered,
|
||||
/checkbox|Inspect traitor|Strength|Newly drawn/,
|
||||
);
|
||||
assert.equal(
|
||||
nexusTraitorReturnAction(v, pending.event, ['anything']),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
nexusTraitorDraw,
|
||||
nexusTraitorReturn,
|
||||
nexusTraitorBattle,
|
||||
holdNexusTraitor,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusReload, nexusReject, nexusAllow } from './fixture-nexus-cards';
|
||||
|
||||
void test('real Cunning draws before return and resumes from JSON with one physical Nexus card spent', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const before = [...g.players[0].traitors],
|
||||
top = g.traitorReserve![0],
|
||||
reserve = [...g.traitorReserve!];
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.players[0].traitors, [...before, top]);
|
||||
assert.deepEqual(g.traitorReserve, reserve.slice(1));
|
||||
assert.equal(g.nexusCards!.cards!.hands.p, null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'harkonnen').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
viewGame(g, 'p').nexusTraitors!.pending!.choices.filter((c) => c.drawn)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
g = nexusTraitorReturn(nexusReload(g), [before[0]]);
|
||||
assert.equal(g.nexusTraitorPending, null);
|
||||
assert.equal(g.nexusTraitorExchanges![0].stage, 'complete');
|
||||
assert.deepEqual(g.players[0].traitors, [...before.slice(1), top]);
|
||||
assert.deepEqual(
|
||||
[...g.traitorReserve!].sort(),
|
||||
[...reserve.slice(1), before[0]].sort(),
|
||||
);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('Secret Ally draws two only in Mentat Pause and safely supports Tleilaxu revealed Face Dancers', () => {
|
||||
for (const faction of ['emperor', 'tleilaxu'] as const) {
|
||||
let g = nexusTraitorFixture({ ownerFaction: faction });
|
||||
if (faction === 'tleilaxu') g.players[0].faceDancers![0].revealed = true;
|
||||
const before = nexusReload(g),
|
||||
drawn = g.traitorReserve!.slice(0, 2);
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.traitorReserve, before.traitorReserve!.slice(2));
|
||||
const pending = viewGame(g, 'p').nexusTraitors!.pending!;
|
||||
assert.equal(pending.count, 2);
|
||||
assert.deepEqual(
|
||||
pending.choices.filter((c) => c.drawn).map((c) => c.id),
|
||||
drawn,
|
||||
);
|
||||
if (faction === 'tleilaxu') {
|
||||
assert.equal(g.players[0].faceDancers!.length, 5);
|
||||
assert.equal(
|
||||
pending.choices.some(
|
||||
(c) => c.id === before.players[0].faceDancers![0].leader,
|
||||
),
|
||||
false,
|
||||
);
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [before.players[0].faceDancers![0].leader, drawn[0]],
|
||||
},
|
||||
/Return|return/,
|
||||
);
|
||||
}
|
||||
g = nexusTraitorReturn(nexusReload(g), drawn);
|
||||
assert.deepEqual(g.players[0].traitors, before.players[0].traitors);
|
||||
assert.deepEqual(g.players[0].faceDancers, before.players[0].faceDancers);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
const early = nexusTraitorFixture({ ownerFaction: 'emperor', phase: 6 }),
|
||||
offer = viewGame(early, 'p').nexusTraitors!.offer!;
|
||||
assert.match(offer.blocked!, /Mentat/);
|
||||
nexusReject(
|
||||
early,
|
||||
'p',
|
||||
{ type: 'nexusTraitorDraw', event: offer.event, mode: 'secretAlly' },
|
||||
/Mentat/,
|
||||
);
|
||||
});
|
||||
void test('drawn and returnable identities remain private across every seat and JSON saves', () => {
|
||||
const initial = nexusTraitorFixture(),
|
||||
alternative = nexusReload(initial);
|
||||
[alternative.traitorReserve![0], alternative.traitorReserve![1]] = [
|
||||
alternative.traitorReserve![1],
|
||||
alternative.traitorReserve![0],
|
||||
];
|
||||
const one = nexusTraitorDraw(initial),
|
||||
two = nexusTraitorDraw(alternative);
|
||||
assert.notDeepEqual(
|
||||
viewGame(one, 'p').nexusTraitors!.pending!.choices,
|
||||
viewGame(two, 'p').nexusTraitors!.pending!.choices,
|
||||
);
|
||||
for (const id of ['q', 'r']) {
|
||||
assert.deepEqual(
|
||||
viewGame(one, id).nexusTraitors,
|
||||
viewGame(two, id).nexusTraitors,
|
||||
);
|
||||
assert.deepEqual(viewGame(one, id).nexusTraitors!.pending!.choices, []);
|
||||
assert.equal(
|
||||
Object.hasOwn(viewGame(one, id), 'nexusTraitorExchanges'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
viewGame(nexusReload(one), 'p').nexusTraitors,
|
||||
viewGame(one, 'p').nexusTraitors,
|
||||
);
|
||||
});
|
||||
void test('stale, foreign, malformed and replayed draw/return actions reject unchanged', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const offer = viewGame(g, 'p').nexusTraitors!.offer!;
|
||||
for (const [id, action] of [
|
||||
['p', { type: 'nexusTraitorDraw', event: 'old', mode: 'cunning' }],
|
||||
['q', { type: 'nexusTraitorDraw', event: offer.event, mode: 'cunning' }],
|
||||
['p', { type: 'nexusTraitorDraw', event: offer.event, mode: 'secretAlly' }],
|
||||
[
|
||||
'p',
|
||||
{
|
||||
type: 'nexusTraitorDraw',
|
||||
event: offer.event,
|
||||
mode: 'cunning',
|
||||
cards: [],
|
||||
},
|
||||
],
|
||||
] as Array<[string, Action]>)
|
||||
nexusReject(g, id, action, /Nexus|Traitor/);
|
||||
g = nexusTraitorDraw(g);
|
||||
const pending = viewGame(g, 'p').nexusTraitors!.pending!,
|
||||
id = pending.choices[0].id;
|
||||
for (const [owner, action] of [
|
||||
['q', { type: 'nexusTraitorReturn', event: pending.event, cards: [id] }],
|
||||
['p', { type: 'nexusTraitorReturn', event: 'old', cards: [id] }],
|
||||
['p', { type: 'nexusTraitorReturn', event: pending.event, cards: [] }],
|
||||
[
|
||||
'p',
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [id, id] },
|
||||
],
|
||||
[
|
||||
'p',
|
||||
{
|
||||
type: 'nexusTraitorReturn',
|
||||
event: pending.event,
|
||||
cards: [g.players[1].traitors[0]],
|
||||
},
|
||||
],
|
||||
] as Array<[string, Action]>)
|
||||
nexusReject(g, owner, action, /Nexus|Return|return/);
|
||||
g = nexusTraitorReturn(g, [id]);
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{ type: 'nexusTraitorReturn', event: pending.event, cards: [id] },
|
||||
/Nexus|return/,
|
||||
);
|
||||
nexusReject(
|
||||
g,
|
||||
'p',
|
||||
{ type: 'nexusTraitorDraw', event: offer.event, mode: 'cunning' },
|
||||
/Nexus|Traitor/,
|
||||
);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('pending exchange blocks ordinary actions and normalization until the owner finishes', () => {
|
||||
const g = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const id of ['p', 'q', 'r'])
|
||||
nexusReject(g, id, { type: 'ready' }, /Nexus|return/);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
assert.deepEqual(applyAction(g, 'q', { type: 'advanceBots' }), g);
|
||||
});
|
||||
void test('all four bot levels draw and return through the shared physical offers', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let g = nexusTraitorFixture();
|
||||
g.players[0].bot = difficulty;
|
||||
const draw = botActions(viewGame(g, 'p')).find(
|
||||
(a) => a.type === 'nexusTraitorDraw',
|
||||
);
|
||||
assert.ok(draw, difficulty);
|
||||
g = applyAction(g, 'p', draw);
|
||||
const back = botActions(viewGame(g, 'p')).find(
|
||||
(a) => a.type === 'nexusTraitorReturn',
|
||||
);
|
||||
assert.ok(back, difficulty);
|
||||
g = applyAction(g, 'p', back);
|
||||
assert.equal(g.nexusTraitorPending, null);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
});
|
||||
void test('altered pending marker, source, hand and completed selection reject save recovery immutably', () => {
|
||||
const pending = nexusTraitorDraw(nexusTraitorFixture());
|
||||
for (const mutate of [
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorPending;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusTraitorExchanges;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorExchanges![0].drawn[0] = 'forged';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusTraitorExchanges![0].source.reserve.reverse();
|
||||
},
|
||||
(g: Game) => {
|
||||
g.players[0].traitors.reverse();
|
||||
},
|
||||
]) {
|
||||
const g = nexusReload(pending);
|
||||
mutate(g);
|
||||
const before = nexusReload(g);
|
||||
assert.throws(() => viewGame(g, 'p'));
|
||||
assert.throws(() => normalizeAutomaticGame(g));
|
||||
assert.throws(() => nexusTraitorReturn(g, [g.players[0].traitors[0]]));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
const done = nexusTraitorReturn(pending, [pending.players[0].traitors[0]]);
|
||||
done.nexusTraitorExchanges![0].returned = ['forged'];
|
||||
assert.throws(() => viewGame(done, 'p'));
|
||||
});
|
||||
void test('a declared traitor remains effective when Cunning returns that physical identity before the other call', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const target = g.players[1].leaders[0].id;
|
||||
holdNexusTraitor(g, 'p', target);
|
||||
g = nexusTraitorBattle(g);
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: true });
|
||||
assert.equal(g.battle!.traitorCalls.p, true);
|
||||
g = nexusTraitorDraw(g);
|
||||
g = nexusTraitorReturn(g, [target]);
|
||||
assert.equal(g.players[0].traitors.includes(target), false);
|
||||
assert.equal(g.battle!.traitorDeclarations!.p.identity, target);
|
||||
g = applyAction(nexusReload(g), 'q', { type: 'traitorCall', call: false });
|
||||
assert.equal(g.players[1].tanks, 5);
|
||||
assert.match(g.log.map((entry) => entry.text).join('\n'), /traitor/i);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('an earlier declined traitor call stays false after Cunning draws the matching identity', () => {
|
||||
let g = nexusTraitorFixture();
|
||||
const target = g.players[1].leaders[0].id;
|
||||
holdNexusTraitor(g, 'p', target);
|
||||
const replacement = g.traitorReserve![0];
|
||||
g.players[0].traitors[g.players[0].traitors.indexOf(target)] = replacement;
|
||||
g.traitorReserve![0] = target;
|
||||
g = nexusTraitorBattle(g);
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.ok(g.players[0].traitors.includes(target));
|
||||
g = nexusTraitorReturn(g, [
|
||||
g.players[0].traitors.find((id) => id !== target)!,
|
||||
]);
|
||||
assert.equal(g.battle!.traitorCalls.p, false);
|
||||
nexusReject(g, 'p', { type: 'traitorCall', call: true }, /Traitor decision/);
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
void test('Cunning suspends a real native Prescience response without allowing its automatic passes or answer during return', () => {
|
||||
let g = nexusTraitorFixture({ opponentFaction: 'atreides' });
|
||||
const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama');
|
||||
assert.ok(karamaIndex >= 0);
|
||||
g.players[2].hand.push(g.deck.splice(karamaIndex, 1)[0]);
|
||||
g = nexusTraitorBattle(g, false);
|
||||
g = applyAction(g, 'q', { type: 'prescience', field: 'dial' });
|
||||
assert.equal(g.response?.kind, 'prescience');
|
||||
const response = structuredClone(g.response),
|
||||
preparation = structuredClone(g.battle!.preparation);
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.response, response);
|
||||
assert.deepEqual(g.battle!.preparation, preparation);
|
||||
nexusReject(g, 'r', { type: 'passResponse' }, /Nexus|return/);
|
||||
nexusReject(g, 'p', { type: 'prescienceAnswer', value: 0 }, /Nexus|return/);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)), nexusReload(g));
|
||||
g = nexusTraitorReturn(g, [g.players[0].traitors[0]]);
|
||||
g = nexusAllow(g);
|
||||
g = applyAction(g, 'p', { type: 'prescienceAnswer', value: 0 });
|
||||
assert.equal(g.battle!.prescience!.value, 0);
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
|
||||
void test('Cunning holds a real post-victory capture decision until its private return finishes', () => {
|
||||
let g = nexusTraitorFixture({ advanced: true });
|
||||
const target = g.players[1].leaders[0].id;
|
||||
holdNexusTraitor(g, 'p', target);
|
||||
g = nexusTraitorBattle(g);
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: true });
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: false });
|
||||
assert.equal(g.decision?.kind, 'captureOffer');
|
||||
const decision = structuredClone(g.decision);
|
||||
g = nexusTraitorDraw(g);
|
||||
assert.deepEqual(g.decision, decision);
|
||||
nexusReject(g, 'p', { type: 'decision', accept: false }, /Nexus|return/);
|
||||
assert.deepEqual(normalizeAutomaticGame(nexusReload(g)).decision, decision);
|
||||
g = nexusTraitorReturn(g, [g.players[0].traitors[0]]);
|
||||
assert.deepEqual(g.decision, decision);
|
||||
g = applyAction(g, 'p', { type: 'decision', accept: false });
|
||||
nexusTraitorInventory(g);
|
||||
});
|
||||
|
||||
void test('fresh Advanced CHOAM and Ix fixtures retain the printed Auditor and Cheap Hero traitor census', () => {
|
||||
for (const options of [
|
||||
{ advanced: true, ownerFaction: 'choam' as const },
|
||||
{ ix: true },
|
||||
]) {
|
||||
let g = nexusTraitorFixture(options);
|
||||
const all = [...g.traitorReserve!, ...g.players.flatMap((p) => p.traitors)];
|
||||
if (options.ix) assert.ok(all.includes('cheap-hero-traitor'));
|
||||
else
|
||||
assert.ok(
|
||||
all.includes(
|
||||
g.players[0].leaders.find((l) => l.id.includes('auditor'))!.id,
|
||||
),
|
||||
);
|
||||
g = nexusTraitorDraw(g);
|
||||
const selected = viewGame(g, 'p')
|
||||
.nexusTraitors!.pending!.choices.slice(0, options.ownerFaction ? 2 : 1)
|
||||
.map((c) => c.id);
|
||||
g = nexusTraitorReturn(g, selected);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
beginNexusTraitorExchange,
|
||||
finishNexusTraitorExchange,
|
||||
validateNexusTraitorExchange,
|
||||
validateNexusTraitorSnapshot,
|
||||
validateNexusTraitorHistory,
|
||||
nexusTraitorReturnChoices,
|
||||
type NexusTraitorSnapshot,
|
||||
type NexusTraitorExchange,
|
||||
} from '../game/nexus-traitor-exchange';
|
||||
|
||||
const universe = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
function fixture(
|
||||
mode: 'cunning' | 'secretAlly' = 'cunning',
|
||||
dancer = false,
|
||||
): NexusTraitorSnapshot {
|
||||
return {
|
||||
reserve: ['e', 'f', 'g', 'h'],
|
||||
players: [
|
||||
dancer
|
||||
? {
|
||||
id: 'owner',
|
||||
faction: 'tleilaxu',
|
||||
traitors: [],
|
||||
faceDancers: [
|
||||
{ leader: 'a', revealed: true },
|
||||
{ leader: 'b', revealed: false },
|
||||
{ leader: 'c', revealed: true },
|
||||
],
|
||||
}
|
||||
: {
|
||||
id: 'owner',
|
||||
faction: mode === 'cunning' ? 'harkonnen' : 'emperor',
|
||||
traitors: ['a', 'b', 'c'],
|
||||
},
|
||||
{ id: 'other', faction: 'guild', traitors: ['d'] },
|
||||
],
|
||||
};
|
||||
}
|
||||
function input(mode: 'cunning' | 'secretAlly' = 'cunning') {
|
||||
return { event: 'exchange-1', owner: 'owner', mode, turn: 3, phase: 8 };
|
||||
}
|
||||
void test('Cunning draws the original top card before return and shuffles only after choosing from the augmented hand', () => {
|
||||
const initial = fixture(),
|
||||
before = reload(initial);
|
||||
const begun = beginNexusTraitorExchange(initial, universe, input());
|
||||
assert.deepEqual(initial, before);
|
||||
assert.deepEqual(begun.exchange.before, ['a', 'b', 'c']);
|
||||
assert.deepEqual(begun.exchange.drawn, ['e']);
|
||||
assert.deepEqual(begun.state.players[0].traitors, ['a', 'b', 'c', 'e']);
|
||||
assert.deepEqual(begun.state.reserve, ['f', 'g', 'h']);
|
||||
assert.deepEqual(
|
||||
nexusTraitorReturnChoices(begun.state, universe, begun.exchange),
|
||||
['a', 'b', 'c', 'e'],
|
||||
);
|
||||
let calls = 0;
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['b'],
|
||||
() => {
|
||||
calls++;
|
||||
return 0.999;
|
||||
},
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
assert.deepEqual(done.state.reserve, ['f', 'g', 'h', 'b']);
|
||||
assert.deepEqual(done.state.players[0].traitors, ['a', 'c', 'e']);
|
||||
assert.deepEqual(done.state.players[1], initial.players[1]);
|
||||
assert.deepEqual(begun.state.players[0].traitors, ['a', 'b', 'c', 'e']);
|
||||
validateNexusTraitorExchange(
|
||||
reload(done.state),
|
||||
universe,
|
||||
reload(done.exchange),
|
||||
);
|
||||
validateNexusTraitorHistory(universe, reload(done.exchange));
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
done.state,
|
||||
universe,
|
||||
done.exchange,
|
||||
['e'],
|
||||
() => 0,
|
||||
),
|
||||
);
|
||||
});
|
||||
void test('Secret Ally draws both cards first and allows returning either original or newly drawn identities', () => {
|
||||
const begun = beginNexusTraitorExchange(
|
||||
fixture('secretAlly'),
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
assert.deepEqual(begun.exchange.drawn, ['e', 'f']);
|
||||
assert.deepEqual(begun.state.players[0].traitors, ['a', 'b', 'c', 'e', 'f']);
|
||||
for (const selected of [
|
||||
['a', 'f'],
|
||||
['e', 'f'],
|
||||
['a', 'b'],
|
||||
]) {
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
selected,
|
||||
() => 0,
|
||||
);
|
||||
assert.equal(done.state.players[0].traitors.length, 3);
|
||||
assert.deepEqual(done.exchange.returned, selected);
|
||||
assert.ok(selected.every((id) => done.state.reserve.includes(id)));
|
||||
validateNexusTraitorHistory(universe, done.exchange);
|
||||
}
|
||||
});
|
||||
void test('Tleilaxu retains original revealed flags and protects those cards while new retained Face Dancers start unrevealed', () => {
|
||||
const initial = fixture('secretAlly', true);
|
||||
const begun = beginNexusTraitorExchange(
|
||||
initial,
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
assert.deepEqual(begun.state.players[0].faceDancers, [
|
||||
...initial.players[0].faceDancers!,
|
||||
{ leader: 'e', revealed: false },
|
||||
{ leader: 'f', revealed: false },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
nexusTraitorReturnChoices(begun.state, universe, begun.exchange),
|
||||
['b', 'e', 'f'],
|
||||
);
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['a', 'e'],
|
||||
() => {
|
||||
throw Error('Invalid selection cannot shuffle');
|
||||
},
|
||||
),
|
||||
);
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['b', 'e'],
|
||||
() => 0.999,
|
||||
);
|
||||
assert.deepEqual(done.state.players[0].faceDancers, [
|
||||
{ leader: 'a', revealed: true },
|
||||
{ leader: 'c', revealed: true },
|
||||
{ leader: 'f', revealed: false },
|
||||
]);
|
||||
assert.deepEqual(done.state.players[0].traitors, []);
|
||||
validateNexusTraitorHistory(universe, reload(done.exchange));
|
||||
});
|
||||
void test('revealed Tleilaxu cards stay protected even when all original cards have revealed', () => {
|
||||
const initial = fixture('secretAlly', true);
|
||||
initial.players[0].faceDancers!.forEach((c) => {
|
||||
c.revealed = true;
|
||||
});
|
||||
const begun = beginNexusTraitorExchange(
|
||||
initial,
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
nexusTraitorReturnChoices(begun.state, universe, begun.exchange),
|
||||
['e', 'f'],
|
||||
);
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['e', 'f'],
|
||||
() => 0.999,
|
||||
);
|
||||
assert.deepEqual(
|
||||
done.state.players[0].faceDancers,
|
||||
initial.players[0].faceDancers,
|
||||
);
|
||||
});
|
||||
void test('insufficient reserve cannot use returns to fund the original draw', () => {
|
||||
const state = fixture('secretAlly');
|
||||
state.players[1].traitors.push(...state.reserve.splice(0, 3));
|
||||
const before = reload(state);
|
||||
assert.throws(() =>
|
||||
beginNexusTraitorExchange(state, universe, input('secretAlly')),
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
});
|
||||
void test('wrong faction modes, malformed metadata and unsupported Betrayal reject before changing custody', () => {
|
||||
const state = fixture(),
|
||||
before = reload(state);
|
||||
for (const change of [
|
||||
{ mode: 'secretAlly' },
|
||||
{ mode: 'betrayal' },
|
||||
{ owner: 'other' },
|
||||
{ owner: 'missing' },
|
||||
{ turn: 0 },
|
||||
{ turn: Infinity },
|
||||
{ phase: 9 },
|
||||
{ phase: 1.5 },
|
||||
{ event: '' },
|
||||
])
|
||||
assert.throws(() =>
|
||||
beginNexusTraitorExchange(state, universe, {
|
||||
...input(),
|
||||
...change,
|
||||
} as ReturnType<typeof input>),
|
||||
);
|
||||
assert.deepEqual(state, before);
|
||||
});
|
||||
void test('invalid exact return counts, duplicates, foreign cards and invalid randomness reject immutable snapshots', () => {
|
||||
const begun = beginNexusTraitorExchange(
|
||||
fixture('secretAlly'),
|
||||
universe,
|
||||
input('secretAlly'),
|
||||
);
|
||||
const before = reload(begun);
|
||||
for (const selected of [
|
||||
[],
|
||||
['a'],
|
||||
['a', 'a'],
|
||||
['a', 'b', 'c'],
|
||||
['a', 'd'],
|
||||
['a', 'unknown'],
|
||||
])
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
selected,
|
||||
() => {
|
||||
throw Error('No RNG on rejected selection');
|
||||
},
|
||||
),
|
||||
);
|
||||
for (const value of [-1, 1, NaN, Infinity])
|
||||
assert.throws(() =>
|
||||
finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['a', 'b'],
|
||||
() => value,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(begun, before);
|
||||
});
|
||||
void test('physical census rejects duplicate, missing and unknown cards and conflicting typed hands', () => {
|
||||
for (const mutate of [
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve[0] = 'a';
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve.pop();
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve[0] = 'unknown';
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[1].id = s.players[0].id;
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[0].id = '__proto__';
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[0].faceDancers = [];
|
||||
},
|
||||
]) {
|
||||
const state = fixture();
|
||||
mutate(state);
|
||||
const before = reload(state);
|
||||
assert.throws(() => validateNexusTraitorSnapshot(state, universe));
|
||||
assert.deepEqual(state, before);
|
||||
}
|
||||
const dancer = fixture('secretAlly', true);
|
||||
dancer.players[0].traitors = ['a'];
|
||||
assert.throws(() => validateNexusTraitorSnapshot(dancer, universe));
|
||||
assert.throws(() =>
|
||||
validateNexusTraitorSnapshot(fixture(), [...universe, 'a']),
|
||||
);
|
||||
});
|
||||
void test('pending state binds original reserve order, owner augmented hand and every other player', () => {
|
||||
const begun = beginNexusTraitorExchange(fixture(), universe, input());
|
||||
for (const mutate of [
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.reserve.reverse();
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
s.players[0].traitors.reverse();
|
||||
},
|
||||
(s: NexusTraitorSnapshot) => {
|
||||
[s.players[1].traitors[0], s.reserve[0]] = [
|
||||
s.reserve[0],
|
||||
s.players[1].traitors[0],
|
||||
];
|
||||
},
|
||||
]) {
|
||||
const state = reload(begun.state);
|
||||
mutate(state);
|
||||
validateNexusTraitorSnapshot(state, universe);
|
||||
assert.throws(() =>
|
||||
validateNexusTraitorExchange(state, universe, begun.exchange),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('signed completed history rejects alteration but remains valid after later legitimate card custody changes', () => {
|
||||
const begun = beginNexusTraitorExchange(fixture(), universe, input());
|
||||
const done = finishNexusTraitorExchange(
|
||||
begun.state,
|
||||
universe,
|
||||
begun.exchange,
|
||||
['a'],
|
||||
() => 0.999,
|
||||
);
|
||||
for (const mutate of [
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.stage = 'return';
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.returned = ['b'];
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.drawn = ['f'];
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.before.reverse();
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.source.reserve.reverse();
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.settledReserve!.reverse();
|
||||
},
|
||||
(r: NexusTraitorExchange) => {
|
||||
r.turn++;
|
||||
},
|
||||
]) {
|
||||
const receipt = reload(done.exchange);
|
||||
mutate(receipt);
|
||||
assert.throws(() => validateNexusTraitorHistory(universe, receipt));
|
||||
}
|
||||
const later = reload(done.state);
|
||||
[later.players[1].traitors[0], later.reserve[0]] = [
|
||||
later.reserve[0],
|
||||
later.players[1].traitors[0],
|
||||
];
|
||||
validateNexusTraitorSnapshot(later, universe);
|
||||
validateNexusTraitorHistory(universe, done.exchange);
|
||||
assert.throws(() =>
|
||||
validateNexusTraitorExchange(later, universe, done.exchange),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { nexusTraitorFixture, nexusTraitorDraw, nexusTraitorInventory } from './fixture-nexus-traitors';
|
||||
import { 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 = nexusTraitorFixture()) {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom('Traitor SQL', 'harkonnen', false, []);
|
||||
const code = made.view.code, tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const ids = ['p', 'q', 'r'];
|
||||
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);
|
||||
function draw(g: Game): Action {
|
||||
const offer = viewGame(g, 'p').nexusTraitors!.offer!;
|
||||
return { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode };
|
||||
}
|
||||
const giveBack = (g: Game, card: string): Action => ({ type: 'nexusTraitorReturn', event: g.nexusTraitorPending, cards: [card] });
|
||||
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 && view.nexusTraitors?.pending) assert.deepEqual(view.nexusTraitors.pending.choices, []);
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusTraitorInventory(g);
|
||||
}
|
||||
void test('Nexus traitor SQL lost draw and return responses restore exact private custody without redrawing or reshuffling', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const action = draw(f.initial);
|
||||
await f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock);
|
||||
let g = await f.restart().readRoom(f.code);
|
||||
assert.equal(g.players[0].traitors.length, 5);
|
||||
await restored(f, g);
|
||||
let before = row(f);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], f.initial.version, action, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, action, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
const returned = giveBack(g, g.nexusTraitorExchanges![0].drawn[0]);
|
||||
const version = g.version;
|
||||
await f.rooms.act(f.code, f.auths[0], version, returned, clock);
|
||||
g = await f.restart().readRoom(f.code); before = row(f);
|
||||
assert.equal(g.players[0].traitors.length, 4);
|
||||
assert.equal(g.nexusTraitorExchanges![0].stage, 'complete');
|
||||
await restored(f, g);
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], version, returned, clock));
|
||||
await assert.rejects(f.rooms.act(f.code, f.auths[0], g.version, returned, clock));
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('Nexus traitor SQL competing returns commit one selection and one shuffle while preserving unrelated rooms', async () => {
|
||||
const f = await fixture(nexusTraitorDraw(nexusTraitorFixture()));
|
||||
try {
|
||||
const other = await f.rooms.createRoom('Unrelated', 'atreides', false, []);
|
||||
const unrelated = f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(other.view.code);
|
||||
f.writes.length = 0;
|
||||
let arrivals = 0; let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
|
||||
const results = await Promise.allSettled(f.initial.players[0].traitors.slice(0, 2).map(card =>
|
||||
f.restart().act(f.code, f.auths[0], f.initial.version, giveBack(f.initial, card), clock)));
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const g = await f.rooms.readRoom(f.code);
|
||||
assert.equal(g.version, f.initial.version + 1);
|
||||
assert.equal(g.nexusTraitorExchanges!.length, 1);
|
||||
assert.equal(g.nexusTraitorExchanges![0].returned.length, 1);
|
||||
await restored(f, g);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT state,version FROM rooms WHERE code = ?').get(other.view.code), unrelated);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('Nexus traitor SQL rejects lost preceding-decision evidence before reads or writes', async () => {
|
||||
const f = await fixture(nexusTraitorDraw(nexusTraitorFixture()));
|
||||
try {
|
||||
for (const damage of [
|
||||
(g: Game) => { g.nexusTraitorParent = null; },
|
||||
(g: Game) => { g.ready.push('q'); },
|
||||
(g: Game) => { g.nexusTraitorExchanges![0].drawn[0] = 'missing'; },
|
||||
]) {
|
||||
const bad = structuredClone(f.initial); damage(bad);
|
||||
f.sqlite.prepare('UPDATE rooms SET state = ? WHERE code = ?').run(JSON.stringify(bad), f.code);
|
||||
f.writes.length = 0; const before = row(f);
|
||||
await assert.rejects(f.restart().readSeatView(f.code, f.auths[0]));
|
||||
await assert.rejects(f.restart().act(f.code, f.auths[0], bad.version, giveBack(f.initial, f.initial.players[0].traitors[0]), clock));
|
||||
await f.restart().continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createTraitorDeclaration,
|
||||
validateTraitorDeclaration,
|
||||
validateTraitorDeclarations,
|
||||
type TraitorDeclarationContext,
|
||||
type TraitorDeclaration,
|
||||
} from '../game/traitor-declarations';
|
||||
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
||||
|
||||
const context = (harkonnen = false): TraitorDeclarationContext => ({
|
||||
event: 'battle-4',
|
||||
attacker: 'a',
|
||||
defender: 'g',
|
||||
plans: { a: { leader: 'a-leader' }, g: { leader: 'g-leader' } },
|
||||
players: [
|
||||
{
|
||||
id: 'a',
|
||||
faction: 'atreides',
|
||||
ally: 'h',
|
||||
traitors: harkonnen ? ['a-other'] : ['g-leader'],
|
||||
},
|
||||
{ id: 'g', faction: 'guild', traitors: ['a-leader'] },
|
||||
{
|
||||
id: 'h',
|
||||
faction: 'harkonnen',
|
||||
ally: 'a',
|
||||
traitors: harkonnen ? ['g-leader'] : ['h-other'],
|
||||
},
|
||||
],
|
||||
heroLeaderIds: [],
|
||||
});
|
||||
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
void test('declarations preserve a valid call after its original identity leaves the hand', () => {
|
||||
for (const voter of ['a', 'g', 'h']) {
|
||||
const c = context(voter === 'h');
|
||||
const before = JSON.stringify(c);
|
||||
const receipt = createTraitorDeclaration(c, voter);
|
||||
assert.equal(JSON.stringify(c), before);
|
||||
c.players.find((p) => p.id === voter)!.traitors = ['other-identity'];
|
||||
const changed = JSON.stringify(c);
|
||||
validateTraitorDeclaration(c, reload(receipt));
|
||||
validateTraitorDeclarations(c, { [voter]: true }, { [voter]: receipt }, 1);
|
||||
assert.equal(JSON.stringify(c), changed);
|
||||
assert.throws(() => createTraitorDeclaration(c, voter), /must hold/);
|
||||
assert.equal(receipt.identity, voter === 'g' ? 'a-leader' : 'g-leader');
|
||||
}
|
||||
});
|
||||
|
||||
void test('an allied Harkonnen declaration binds its beneficiary separately from its voter', () => {
|
||||
const c = context(true);
|
||||
const receipt = createTraitorDeclaration(c, 'h');
|
||||
assert.equal(receipt.voter, 'h');
|
||||
assert.equal(receipt.beneficiary, 'a');
|
||||
assert.equal(receipt.target, 'g');
|
||||
for (const ally of [null, 'g', 'missing']) {
|
||||
const changed = reload(c);
|
||||
changed.players[2].ally = ally;
|
||||
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
||||
}
|
||||
const nonHarkonnen = reload(c);
|
||||
nonHarkonnen.players[2].faction = 'fremen';
|
||||
assert.throws(() => createTraitorDeclaration(nonHarkonnen, 'h'));
|
||||
});
|
||||
|
||||
void test('Cheap Hero receipt binds the sealed card ID to the shared physical traitor identity', () => {
|
||||
const c = context();
|
||||
c.plans.g.leader = 'cheap-hero-card-2';
|
||||
c.heroLeaderIds = ['cheap-hero-card-1', 'cheap-hero-card-2'];
|
||||
c.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
||||
const receipt = createTraitorDeclaration(c, 'a');
|
||||
assert.equal(receipt.leader, 'cheap-hero-card-2');
|
||||
assert.equal(receipt.identity, CHEAP_HERO_TRAITOR);
|
||||
c.players[0].traitors = [];
|
||||
validateTraitorDeclaration(c, receipt);
|
||||
c.plans.g.leader = 'cheap-hero-card-1';
|
||||
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
||||
c.plans.g.leader = 'cheap-hero-card-2';
|
||||
c.heroLeaderIds = [];
|
||||
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
||||
});
|
||||
|
||||
void test('missing leaders, Kwisatz protection and missing custody reject creation without mutation', () => {
|
||||
for (const mutate of [
|
||||
(c: TraitorDeclarationContext) => {
|
||||
c.plans.g.leader = null;
|
||||
},
|
||||
(c: TraitorDeclarationContext) => {
|
||||
c.plans.g.kwisatz = true;
|
||||
},
|
||||
(c: TraitorDeclarationContext) => {
|
||||
c.players[0].traitors = [];
|
||||
},
|
||||
]) {
|
||||
const c = context();
|
||||
mutate(c);
|
||||
const before = JSON.stringify(c);
|
||||
assert.throws(() => createTraitorDeclaration(c, 'a'));
|
||||
assert.equal(JSON.stringify(c), before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('receipt edits, deleted fields and extra fields cannot change the committed identity', () => {
|
||||
const c = context();
|
||||
const original = createTraitorDeclaration(c, 'a');
|
||||
for (const field of Object.keys(original)) {
|
||||
for (const remove of [false, true]) {
|
||||
const receipt = reload(original);
|
||||
const writable = receipt as unknown as Record<string, unknown>;
|
||||
if (remove) delete writable[field];
|
||||
else writable[field] = 'changed';
|
||||
const before = JSON.stringify(receipt);
|
||||
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
||||
assert.equal(JSON.stringify(receipt), before);
|
||||
}
|
||||
}
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclaration(c, {
|
||||
...original,
|
||||
hidden: 'unexpected',
|
||||
} as TraitorDeclaration),
|
||||
);
|
||||
});
|
||||
|
||||
void test('battle, target leader, protection and roster corruption reject while seat ordering does not', () => {
|
||||
const c = context();
|
||||
const receipt = createTraitorDeclaration(c, 'a');
|
||||
for (const mutate of [
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.event = 'another-battle';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.plans.g.leader = 'replacement';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.plans.g.kwisatz = true;
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.players[2].faction = 'fremen';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.players[2].id = 'other-seat';
|
||||
},
|
||||
(copy: TraitorDeclarationContext) => {
|
||||
copy.players[2].id = 'a';
|
||||
},
|
||||
]) {
|
||||
const changed = reload(c);
|
||||
mutate(changed);
|
||||
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
||||
}
|
||||
c.players = [...c.players].reverse();
|
||||
validateTraitorDeclaration(c, receipt);
|
||||
});
|
||||
|
||||
void test('a canceled false call retains evidence, but a false decision cannot be promoted by a new draw', () => {
|
||||
const c = context(true);
|
||||
const receipt = createTraitorDeclaration(c, 'h');
|
||||
validateTraitorDeclarations(c, { h: false }, { h: receipt }, 1);
|
||||
validateTraitorDeclarations(c, { a: false }, {}, 1);
|
||||
c.players[0].traitors = ['g-leader', 'new-traitor'];
|
||||
assert.throws(() => validateTraitorDeclarations(c, { a: true }, {}, 1));
|
||||
assert.throws(() => validateTraitorDeclarations(c, {}, { h: receipt }, 1));
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { h: undefined }, { h: receipt }, 1),
|
||||
);
|
||||
});
|
||||
|
||||
void test('versioned maps require every true call, exact voter keys and both metadata fields', () => {
|
||||
const c = context();
|
||||
const a = createTraitorDeclaration(c, 'a');
|
||||
const g = createTraitorDeclaration(c, 'g');
|
||||
validateTraitorDeclarations(c, { a: true, g: true }, { a, g }, 1);
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { a: true, g: true }, { a }, 1),
|
||||
);
|
||||
assert.throws(() => validateTraitorDeclarations(c, { a: true }, { a: g }, 1));
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { a: true }, undefined, 1),
|
||||
);
|
||||
assert.throws(() => validateTraitorDeclarations(c, { a: true }, { a }));
|
||||
assert.throws(() =>
|
||||
validateTraitorDeclarations(c, { a: true }, { a }, 2 as 1),
|
||||
);
|
||||
// Both absent is intentionally legacy; this does not certify historical calls.
|
||||
validateTraitorDeclarations(c, { a: true }, undefined);
|
||||
});
|
||||
|
||||
void test('later validation does not read any private traitor hand', () => {
|
||||
const c = context();
|
||||
const receipt = createTraitorDeclaration(c, 'a');
|
||||
for (const player of c.players)
|
||||
Object.defineProperty(player, 'traitors', {
|
||||
get() {
|
||||
throw new Error('Private hand was read');
|
||||
},
|
||||
});
|
||||
validateTraitorDeclaration(c, receipt);
|
||||
validateTraitorDeclarations(c, { a: true }, { a: receipt }, 1);
|
||||
});
|
||||
Reference in new issue
Block a user