Implement five CHOAM Nexus Cunning effects with private costs and recovery
This commit is contained in:
1 parent
a6d9235099
commit
290259bac2
24 files changed
+1935
-131
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { gameDistance, splitLocation, TERRITORIES } from '../game/board';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import {
|
||||
nexusTraitorFixture,
|
||||
nexusTraitorInventory,
|
||||
} from './fixture-nexus-traitors';
|
||||
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
|
||||
|
||||
export type NexusChoamEffect =
|
||||
| 'kulon'
|
||||
| 'laLaLa'
|
||||
| 'gamont'
|
||||
| 'baliset'
|
||||
| 'jubba';
|
||||
export const NEXUS_CHOAM_EFFECTS: NexusChoamEffect[] = [
|
||||
'kulon',
|
||||
'laLaLa',
|
||||
'gamont',
|
||||
'baliset',
|
||||
'jubba',
|
||||
];
|
||||
export type NexusChoamFixture = {
|
||||
g: Game;
|
||||
owner: string;
|
||||
target: string;
|
||||
observer: string;
|
||||
cost: Card;
|
||||
karama?: string;
|
||||
effect: NexusChoamEffect;
|
||||
selection: Record<string, unknown>;
|
||||
};
|
||||
export function holdNexusChoamCard(g: Game, owner: string, kind: string): Card {
|
||||
const index = g.deck.findIndex(
|
||||
(c) => c.kind === kind || c.effect === kind || c.name === kind,
|
||||
);
|
||||
assert.ok(index >= 0, `Missing physical ${kind}`);
|
||||
const card = g.deck.splice(index, 1)[0];
|
||||
g.players.find((p) => p.id === owner)!.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Final identities, faction roster, Advanced mode and Nexus components precede
|
||||
* genuine setup. Only conserved later board positions/phase boundaries are
|
||||
* staged; the public revival, move, storm and Mentat choices use real actions. */
|
||||
export function nexusChoamFixture(
|
||||
effect: NexusChoamEffect,
|
||||
options: {
|
||||
seatIds?: [string, string, string];
|
||||
advanced?: boolean;
|
||||
opponentFaction?: 'guild' | 'emperor' | 'richese';
|
||||
costKind?: string;
|
||||
karama?: boolean;
|
||||
} = {},
|
||||
): NexusChoamFixture {
|
||||
const [owner, target, observer] = options.seatIds ?? ['p', 'q', 'r'];
|
||||
const phase =
|
||||
effect === 'laLaLa'
|
||||
? 4
|
||||
: effect === 'gamont'
|
||||
? 7
|
||||
: effect === 'jubba'
|
||||
? 0
|
||||
: 5;
|
||||
let g = nexusTraitorFixture({
|
||||
ownerFaction: 'choam',
|
||||
opponentFaction: options.opponentFaction,
|
||||
advanced: options.advanced ?? true,
|
||||
seatIds: [owner, target, observer],
|
||||
phase,
|
||||
});
|
||||
const cards = g.nexusCards!.cards!,
|
||||
index = cards.deck.indexOf('choam');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck[index] = cards.hands[owner]!;
|
||||
cards.hands[owner] = 'choam';
|
||||
for (const p of g.players) {
|
||||
Object.assign(p, {
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
spice: 20,
|
||||
moved: 0,
|
||||
shipped: true,
|
||||
revived: 0,
|
||||
});
|
||||
if (p.elites)
|
||||
Object.assign(p.elites, {
|
||||
forces: {},
|
||||
reserves: p.faction === 'emperor' ? 5 : 3,
|
||||
tanks: 0,
|
||||
revived: 0,
|
||||
});
|
||||
}
|
||||
Object.assign(g, {
|
||||
active: owner,
|
||||
order: [owner, target, observer],
|
||||
ready: [],
|
||||
phaseOpening: null,
|
||||
response: null,
|
||||
decision: null,
|
||||
pendingRevival: null,
|
||||
choamMarket: null,
|
||||
movementRemaining: [owner, target, observer],
|
||||
spice: {},
|
||||
});
|
||||
const cost = holdNexusChoamCard(g, owner, options.costKind ?? 'projectile');
|
||||
const karama =
|
||||
options.karama === false
|
||||
? undefined
|
||||
: holdNexusChoamCard(g, target, 'karama').id;
|
||||
const p = g.players[0],
|
||||
q = g.players[1];
|
||||
let selection: Record<string, unknown> = {};
|
||||
if (effect === 'laLaLa') {
|
||||
g.revivalRules = newRevivalRules();
|
||||
q.tanks = 4;
|
||||
q.reserves = 16;
|
||||
g = applyAction(g, target, { type: 'revive', amount: 3 });
|
||||
assert.equal(g.decision?.kind, 'choamFreeRevival');
|
||||
selection = { target };
|
||||
} else if (effect === 'jubba') {
|
||||
g.storm = 5;
|
||||
g.stormPending = 3;
|
||||
p.forces = { 'red_chasm:7': 4 };
|
||||
p.reserves = 16;
|
||||
q.forces = { 'red_chasm:7': 3 };
|
||||
q.reserves = 17;
|
||||
g.spice = { 'red_chasm:7': 8 };
|
||||
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamStorm');
|
||||
selection = { territory: 'red_chasm' };
|
||||
} else if (effect === 'baliset') {
|
||||
const source = 'red_chasm:7';
|
||||
const to = TERRITORIES.flatMap((t) =>
|
||||
t.sectors.map((s) => `${t.id}:${s}`),
|
||||
).find((key) => {
|
||||
const loc = splitLocation(key);
|
||||
return (
|
||||
loc.territory !== 'red_chasm' &&
|
||||
loc.sector !== g.storm &&
|
||||
gameDistance(
|
||||
g,
|
||||
source,
|
||||
key,
|
||||
(k) => splitLocation(k).sector === g.storm,
|
||||
) === 1
|
||||
);
|
||||
});
|
||||
assert.ok(to);
|
||||
p.forces = { [to]: 1 };
|
||||
p.reserves = 19;
|
||||
q.forces = { [source]: 3 };
|
||||
q.reserves = 17;
|
||||
g.active = target;
|
||||
g = applyAction(g, target, {
|
||||
type: 'move',
|
||||
from: source,
|
||||
amount: 2,
|
||||
...splitLocation(to),
|
||||
});
|
||||
assert.equal(g.decision?.kind, 'choamMovement');
|
||||
selection = { target, territory: splitLocation(to).territory };
|
||||
} else if (effect === 'gamont') {
|
||||
q.forces = { 'arrakeen:10': 2 };
|
||||
q.reserves = 18;
|
||||
for (const id of g.order)
|
||||
if (g.phase === 7) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamMarket');
|
||||
g = applyAction(g, owner, { type: 'decision', done: true });
|
||||
for (const id of g.order)
|
||||
if (!g.ready.includes(id)) g = applyAction(g, id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'choamMarket');
|
||||
g = applyAction(g, owner, { type: 'decision', done: true });
|
||||
assert.equal(g.decision?.kind, 'choamMentat');
|
||||
selection = { target, from: 'arrakeen:10', elite: 0 };
|
||||
} else {
|
||||
p.forces = { 'red_chasm:7': 3 };
|
||||
p.reserves = 17;
|
||||
}
|
||||
nexusChoamInventory(g);
|
||||
return { g, owner, target, observer, cost, karama, effect, selection };
|
||||
}
|
||||
export function nexusChoamRequest(f: NexusChoamFixture, g = f.g): Action {
|
||||
return {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: f.cost.id,
|
||||
effect: f.effect,
|
||||
nexus: JSON.stringify([
|
||||
'nexusChoam',
|
||||
g.turn,
|
||||
g.phase,
|
||||
f.owner,
|
||||
f.cost.id,
|
||||
f.effect,
|
||||
]),
|
||||
...f.selection,
|
||||
};
|
||||
}
|
||||
export function nexusChoamInventory(g: Game) {
|
||||
if (!g.richeseCache) return nexusTraitorInventory(g);
|
||||
const extra = richeseCards(),
|
||||
ids = new Set(extra.map((card) => card.id));
|
||||
assert.deepEqual(
|
||||
[
|
||||
...g.deck,
|
||||
...g.discard,
|
||||
...g.players.flatMap((p) => p.hand),
|
||||
...g.richeseCache,
|
||||
]
|
||||
.map((card) => card.id)
|
||||
.sort(),
|
||||
[...treacheryDeck(g.expansions), ...extra].map((card) => card.id).sort(),
|
||||
);
|
||||
// The shared traitor fixture checks the ordinary deck. Above we independently
|
||||
// include every native Richese component, then reuse its other physical checks.
|
||||
const ordinary = structuredClone(g);
|
||||
ordinary.deck = ordinary.deck.filter((card) => !ids.has(card.id));
|
||||
ordinary.discard = ordinary.discard.filter((card) => !ids.has(card.id));
|
||||
for (const p of ordinary.players)
|
||||
p.hand = p.hand.filter((card) => !ids.has(card.id));
|
||||
nexusTraitorInventory(ordinary);
|
||||
}
|
||||
export const nexusChoamAllow = nexusAllow;
|
||||
export const nexusChoamReload = nexusReload;
|
||||
@@ -63,10 +63,7 @@ export function nexusTraitorFixture(
|
||||
}
|
||||
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'))
|
||||
) {
|
||||
if (options.ix || 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;
|
||||
@@ -88,10 +85,7 @@ export function nexusTraitorFixture(
|
||||
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'))
|
||||
) {
|
||||
if (options.ix || 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) {
|
||||
|
||||
@@ -123,8 +123,8 @@ void test('the owned face highlights its mode from the public roster and all thr
|
||||
void test('controls expose public counts and waiting players, disable while busy, and offer no effect play action', () => {
|
||||
const view = fixture();
|
||||
const html = markup(view, true);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning/);
|
||||
assert.match(html, /other effects remain unavailable/);
|
||||
assert.match(html, /Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor and CHOAM Cunning/);
|
||||
assert.match(html, /other unfinished effects remain unavailable/);
|
||||
assert.doesNotMatch(html, /other card effects are not playable yet/);
|
||||
assert.match(html, /10 in deck · 0 discarded · 2 held/);
|
||||
assert.match(html, /Waiting for Nexus choices: Harkonnen/);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusChoamFixture,
|
||||
nexusChoamRequest,
|
||||
nexusChoamReload,
|
||||
} from './fixture-nexus-choam';
|
||||
|
||||
for (const effect of ['laLaLa', 'baliset', 'jubba'] as const) {
|
||||
void test(`CHOAM Cunning ${effect} binds its original suspended operation before a valid-looking saved replacement`, () => {
|
||||
const f = nexusChoamFixture(effect);
|
||||
const pending = applyAction(f.g, f.owner, nexusChoamRequest(f));
|
||||
const g = nexusChoamReload(pending);
|
||||
// Each replacement would be legal by itself. It was not the operation
|
||||
// which earned this genuine reaction and cannot replace that saved parent.
|
||||
if (effect === 'laLaLa') {
|
||||
assert.equal(g.pendingRevival!.amount, 3);
|
||||
g.pendingRevival!.amount = 2;
|
||||
g.pendingRevival!.normalCost = 2;
|
||||
g.pendingRevival!.cost = 2;
|
||||
} else if (effect === 'baliset') {
|
||||
assert.equal(g.pendingChoamMove!.total, 2);
|
||||
g.pendingChoamMove!.total = 1;
|
||||
g.pendingChoamMove!.group[0][1] = 1;
|
||||
} else {
|
||||
assert.equal(g.stormResolution!.distance, 3);
|
||||
g.stormResolution!.distance = 4;
|
||||
}
|
||||
const before = JSON.stringify(g);
|
||||
const checks: ((state: Game) => unknown)[] = [
|
||||
...g.players.map((p) => (state: Game) => viewGame(state, p.id)),
|
||||
(state) => normalizeAutomaticGame(state),
|
||||
(state) =>
|
||||
applyAction(state, f.target, {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama,
|
||||
}),
|
||||
(state) => applyAction(state, f.owner, { type: 'passResponse' }),
|
||||
];
|
||||
for (const check of checks) {
|
||||
assert.throws(
|
||||
() => check(g),
|
||||
/CHOAM|Cunning|original|parent|source|continuation/i,
|
||||
);
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
choamPowerAction,
|
||||
choamPowerBotPlay,
|
||||
choamPowerPlays,
|
||||
} from '../game/choam-power-options';
|
||||
import {
|
||||
NEXUS_CHOAM_EFFECTS,
|
||||
nexusChoamFixture,
|
||||
nexusChoamInventory,
|
||||
holdNexusChoamCard,
|
||||
} from './fixture-nexus-choam';
|
||||
|
||||
void test('all four CHOAM profiles use projected Cunning fuel at existing effect timing and preserve the Easy revival policy', () => {
|
||||
for (const effect of NEXUS_CHOAM_EFFECTS)
|
||||
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const f = nexusChoamFixture(effect);
|
||||
const view = viewGame(f.g, f.owner);
|
||||
view.players.find((player) => player.id === f.owner)!.bot = profile;
|
||||
const before = structuredClone(view);
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, `${profile}/${effect}`);
|
||||
if (effect === 'laLaLa' && profile === 'Easy')
|
||||
assert.deepEqual(action, { type: 'decision', decline: true });
|
||||
else {
|
||||
assert.equal(
|
||||
action.nexus,
|
||||
choamPowerPlays(view, effect).find(
|
||||
(play) => play.card.id === f.cost.id && play.source === 'nexus',
|
||||
)!.event,
|
||||
);
|
||||
assert.equal(action.card, f.cost.id);
|
||||
assert.equal(action.effect, effect);
|
||||
}
|
||||
const result = applyAction(f.g, f.owner, action);
|
||||
nexusChoamInventory(result);
|
||||
assert.deepEqual(view, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('CHOAM fuel ranking favors printed powers then low-value private costs and never consumes blocked cards', () => {
|
||||
const f = nexusChoamFixture('kulon');
|
||||
const fuel = holdNexusChoamCard(f.g, f.owner, 'Baliset');
|
||||
const value = (card: { id: string }) => (card.id === fuel.id ? 0 : 10);
|
||||
const view = viewGame(f.g, f.owner);
|
||||
const selected = choamPowerBotPlay(view, 'kulon', value)!;
|
||||
assert.equal(selected.source, 'nexus');
|
||||
assert.equal(selected.card.id, fuel.id);
|
||||
for (const p of view.players.filter((player) => player.id !== f.owner))
|
||||
for (const key of ['hand', 'spice', 'traitors'])
|
||||
Object.defineProperty(p, key, {
|
||||
get() {
|
||||
throw new Error('Private opponent field read');
|
||||
},
|
||||
});
|
||||
assert.equal(choamPowerBotPlay(view, 'kulon', value)!.card.id, fuel.id);
|
||||
for (const play of view.choamWorthless!.plays)
|
||||
if (play.card.id === fuel.id) play.blocked = 'Reserved';
|
||||
assert.equal(choamPowerBotPlay(view, 'kulon', value)!.card.id, f.cost.id);
|
||||
assert.equal(choamPowerAction(view, selected), null);
|
||||
const printed = holdNexusChoamCard(f.g, f.owner, 'Kulon');
|
||||
const regular = choamPowerBotPlay(viewGame(f.g, f.owner), 'kulon', (card) =>
|
||||
card.id === printed.id ? 100 : 0,
|
||||
)!;
|
||||
assert.equal(regular.source, 'printed');
|
||||
assert.deepEqual(choamPowerAction(viewGame(f.g, f.owner), regular), {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: printed.id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type GameView } from '../game/engine';
|
||||
import { ChoamWorthless } from '../components/choam-worthless';
|
||||
import { ChoamBaliset } from '../components/choam-baliset';
|
||||
import { ChoamStorm } from '../components/choam-storm';
|
||||
import { ChoamGamont } from '../components/choam-gamont';
|
||||
import {
|
||||
CHOAM_POWER_NAMES,
|
||||
choamPowerAction,
|
||||
choamPowerPlays,
|
||||
} from '../game/choam-power-options';
|
||||
import {
|
||||
NEXUS_CHOAM_EFFECTS,
|
||||
nexusChoamFixture,
|
||||
nexusChoamInventory,
|
||||
nexusChoamRequest,
|
||||
holdNexusChoamCard,
|
||||
type NexusChoamEffect,
|
||||
} from './fixture-nexus-choam';
|
||||
|
||||
function html(game: GameView, effect: NexusChoamEffect, busy = false) {
|
||||
const component =
|
||||
effect === 'jubba'
|
||||
? ChoamStorm
|
||||
: effect === 'baliset'
|
||||
? ChoamBaliset
|
||||
: effect === 'gamont'
|
||||
? ChoamGamont
|
||||
: ChoamWorthless;
|
||||
return renderToStaticMarkup(
|
||||
createElement(component, { game, busy, act() {} }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('five actual CHOAM windows show physical weapon fuel separately from the chosen Worthless effect and submit the real source action', () => {
|
||||
for (const effect of NEXUS_CHOAM_EFFECTS) {
|
||||
const f = nexusChoamFixture(effect);
|
||||
const view = viewGame(f.g, f.owner),
|
||||
before = structuredClone(view);
|
||||
const play = choamPowerPlays(view, effect).find(
|
||||
(candidate) =>
|
||||
candidate.source === 'nexus' && candidate.card.id === f.cost.id,
|
||||
)!;
|
||||
assert.ok(play, effect);
|
||||
assert.equal(play.blocked, null, effect);
|
||||
const markup = html(view, effect);
|
||||
assert.ok(markup.includes(f.cost.name), effect);
|
||||
assert.ok(markup.includes(CHOAM_POWER_NAMES[effect]), effect);
|
||||
assert.match(markup, /Inspect card/);
|
||||
assert.match(markup, /spends CHOAM Nexus Cunning once/);
|
||||
assert.match(markup, /the Nexus stays spent/);
|
||||
assert.match(html(view, effect, true), /disabled=""/);
|
||||
const action = choamPowerAction(view, play, f.selection);
|
||||
assert.deepEqual(action, nexusChoamRequest(f));
|
||||
const pending = applyAction(f.g, f.owner, action!);
|
||||
assert.equal(pending.response?.intent, CHOAM_POWER_NAMES[effect]);
|
||||
assert.notEqual(pending.response?.intent, f.cost.name);
|
||||
assert.deepEqual(view, before);
|
||||
nexusChoamInventory(pending);
|
||||
}
|
||||
});
|
||||
|
||||
void test('CHOAM cost selection keeps printed powers unchanged and exposes explicit source blocks including Kull', () => {
|
||||
const f = nexusChoamFixture('kulon');
|
||||
const printed = holdNexusChoamCard(f.g, f.owner, 'Kulon');
|
||||
const view = viewGame(f.g, f.owner);
|
||||
const regular = choamPowerPlays(view, 'kulon').find(
|
||||
(play) => play.source === 'printed',
|
||||
)!;
|
||||
assert.deepEqual(choamPowerAction(view, regular), {
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: printed.id,
|
||||
});
|
||||
assert.equal(
|
||||
choamPowerAction(view, regular, { nexus: 'forged', effect: 'jubba' }),
|
||||
null,
|
||||
);
|
||||
assert.match(html(view, 'kulon'), /Card to use for Kulon/);
|
||||
assert.match(html(view, 'kulon'), /printed power/);
|
||||
const blocked = structuredClone(view);
|
||||
for (const play of blocked.choamWorthless!.plays)
|
||||
if (play.effect === 'kulon')
|
||||
play.blocked = 'This card is reserved for another committed action.';
|
||||
assert.match(html(blocked, 'kulon'), /reserved for another committed action/);
|
||||
assert.match(html(blocked, 'kulon'), /disabled=""[^>]*>Use Kulon/);
|
||||
for (const play of choamPowerPlays(blocked, 'kulon'))
|
||||
assert.equal(choamPowerAction(blocked, { ...play, blocked: null }), null);
|
||||
const kull = choamPowerPlays(view, 'kull').find(
|
||||
(play) => play.source === 'nexus',
|
||||
)!;
|
||||
assert.ok(kull.blocked);
|
||||
assert.equal(choamPowerAction(view, kull), null);
|
||||
assert.match(html(view, 'kulon'), /Kull Wahad.*not implemented/);
|
||||
});
|
||||
|
||||
void test('CHOAM shared actions fence stale source events, actors and interruption windows without reading outsider private choices', () => {
|
||||
const f = nexusChoamFixture('kulon');
|
||||
const view = viewGame(f.g, f.owner);
|
||||
const play = choamPowerPlays(view, 'kulon').find(
|
||||
(candidate) => candidate.source === 'nexus',
|
||||
)!;
|
||||
assert.equal(choamPowerAction(view, { ...play, event: 'stale' }), null);
|
||||
for (const change of [
|
||||
(v: GameView) => {
|
||||
v.phase = 4;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.active = f.target;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.automaticContinuationPending = true;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.decision = { kind: 'choamMentat', player: f.owner };
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.players.find((p) => p.id === f.owner)!.ally = f.target;
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.nexusCards!.card = null;
|
||||
},
|
||||
]) {
|
||||
const blocked = structuredClone(view);
|
||||
change(blocked);
|
||||
assert.equal(choamPowerAction(blocked, play), null);
|
||||
}
|
||||
for (const id of [f.target, f.observer]) {
|
||||
const other = viewGame(f.g, id);
|
||||
assert.equal(other.choamWorthless, null);
|
||||
Object.defineProperty(other, 'choamWorthless', {
|
||||
get() {
|
||||
throw new Error('Opponent cost choices read');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(choamPowerPlays(other, 'kulon'), []);
|
||||
assert.equal(choamPowerAction(other, play), null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
|
||||
import {
|
||||
NEXUS_CHOAM_EFFECTS,
|
||||
nexusChoamFixture,
|
||||
holdNexusChoamCard,
|
||||
nexusChoamRequest,
|
||||
nexusChoamAllow,
|
||||
nexusChoamInventory,
|
||||
nexusChoamReload,
|
||||
} from './fixture-nexus-choam';
|
||||
|
||||
function reject(g: Game, owner: string, action: Action) {
|
||||
const before = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, owner, action));
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
}
|
||||
for (const effect of NEXUS_CHOAM_EFFECTS) {
|
||||
void test(`CHOAM Nexus ${effect} spends Nexus before the response and applies its real effect once`, () => {
|
||||
const f = nexusChoamFixture(effect),
|
||||
before = structuredClone(f.g);
|
||||
const offer = viewGame(f.g, f.owner).choamWorthless!.plays.find(
|
||||
(play) =>
|
||||
play.source === 'nexus' &&
|
||||
play.effect === effect &&
|
||||
play.card.id === f.cost.id,
|
||||
)!;
|
||||
assert.ok(offer && offer.source === 'nexus');
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.event, nexusChoamRequest(f).nexus);
|
||||
let g = applyAction(f.g, f.owner, nexusChoamRequest(f));
|
||||
assert.equal(g.response?.kind, 'choamWorthless');
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((card) => card === 'choam').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
g.players[0].hand.find((card) => card.id === f.cost.id),
|
||||
f.cost,
|
||||
);
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => p.forces),
|
||||
before.players.map((p) => p.forces),
|
||||
);
|
||||
g = nexusChoamAllow(nexusChoamReload(g));
|
||||
assert.equal(g.nexusChoamLast?.stage, 'complete');
|
||||
assert.deepEqual(
|
||||
g.discard.find((card) => card.id === f.cost.id),
|
||||
f.cost,
|
||||
);
|
||||
assert.equal(g.discard.filter((card) => card.id === f.cost.id).length, 1);
|
||||
assert.equal(
|
||||
g.players[0].hand.some((card) => card.id === f.cost.id),
|
||||
false,
|
||||
);
|
||||
if (effect === 'kulon')
|
||||
assert.deepEqual(g.choamMovement, { turn: g.turn, bonus: 1 });
|
||||
if (effect === 'laLaLa') {
|
||||
assert.equal(g.players[1].tanks, 4);
|
||||
assert.equal(g.players[1].spice, 20);
|
||||
assert.equal(g.pendingRevival, null);
|
||||
}
|
||||
if (effect === 'gamont') {
|
||||
assert.equal(g.players[1].forces['arrakeen:10'], 1);
|
||||
assert.equal(g.players[1].reserves, 19);
|
||||
assert.equal(g.decision?.kind, 'choamMentat');
|
||||
}
|
||||
if (effect === 'baliset') {
|
||||
assert.deepEqual(g.players[1].forces, before.players[1].forces);
|
||||
assert.equal(g.players[1].moved, 0);
|
||||
assert.equal(g.pendingChoamMove, null);
|
||||
}
|
||||
if (effect === 'jubba') {
|
||||
assert.equal(g.players[0].forces['red_chasm:7'], 4);
|
||||
assert.equal(g.players[1].tanks, 3);
|
||||
assert.equal(g.spice['red_chasm:7'], undefined);
|
||||
}
|
||||
reject(g, f.owner, nexusChoamRequest(f));
|
||||
nexusChoamInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(nexusChoamReload(g), p.id), viewGame(g, p.id));
|
||||
});
|
||||
void test(`CHOAM Nexus ${effect} Karama retains the actual cost and resumes the original public context`, () => {
|
||||
const f = nexusChoamFixture(effect);
|
||||
let g = applyAction(f.g, f.owner, nexusChoamRequest(f));
|
||||
g = applyAction(nexusChoamReload(g), f.target, {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama,
|
||||
});
|
||||
assert.equal(g.nexusChoamLast?.stage, 'canceled');
|
||||
assert.deepEqual(
|
||||
g.players[0].hand.find((card) => card.id === f.cost.id),
|
||||
f.cost,
|
||||
);
|
||||
assert.equal(
|
||||
g.discard.some((card) => card.id === f.cost.id),
|
||||
false,
|
||||
);
|
||||
assert.equal(g.discard.filter((card) => card.id === f.karama).length, 1);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
if (effect === 'kulon') assert.equal(g.choamMovement, undefined);
|
||||
if (effect === 'laLaLa') {
|
||||
assert.equal(g.players[1].reserves, 19);
|
||||
assert.equal(g.players[1].tanks, 1);
|
||||
assert.equal(g.players[1].spice, 16);
|
||||
}
|
||||
if (effect === 'gamont') {
|
||||
assert.equal(g.players[1].forces['arrakeen:10'], 2);
|
||||
assert.equal(g.decision?.kind, 'choamMentat');
|
||||
}
|
||||
if (effect === 'baliset') {
|
||||
assert.equal(g.players[1].forces['red_chasm:7'], 1);
|
||||
assert.equal(g.players[1].moved, 1);
|
||||
}
|
||||
if (effect === 'jubba') {
|
||||
assert.equal(g.decision?.kind, 'choamStorm');
|
||||
g = applyAction(g, f.owner, { type: 'decision', decline: true });
|
||||
assert.equal(g.players[0].tanks, 4);
|
||||
assert.equal(g.players[1].tanks, 3);
|
||||
}
|
||||
reject(g, f.owner, nexusChoamRequest(f));
|
||||
nexusChoamInventory(g);
|
||||
});
|
||||
}
|
||||
void test('CHOAM Nexus rejects stale events, wrong owner/effect, unsupported Kull and malformed costs immutably', () => {
|
||||
const f = nexusChoamFixture('kulon'),
|
||||
action = nexusChoamRequest(f);
|
||||
for (const bad of [
|
||||
{ ...action, nexus: 'stale' },
|
||||
{ ...action, effect: 'kull' },
|
||||
{ ...action, card: 'unknown' },
|
||||
{ ...action, effect: 'gamont' },
|
||||
])
|
||||
reject(f.g, f.owner, bad);
|
||||
reject(f.g, f.target, action);
|
||||
const unsupported = viewGame(f.g, f.owner).choamWorthless!.plays.find(
|
||||
(play) => play.effect === 'kull',
|
||||
);
|
||||
assert.ok(unsupported?.blocked);
|
||||
});
|
||||
void test('CHOAM Nexus hidden physical cost kinds have identical opponent views until actual discard', () => {
|
||||
const f = nexusChoamFixture('kulon'),
|
||||
other = structuredClone(f.g);
|
||||
const index = other.deck.findIndex((card) => card.kind === 'poison');
|
||||
assert.ok(index >= 0);
|
||||
const cost = other.deck[index];
|
||||
other.deck[index] = other.players[0].hand[0];
|
||||
other.players[0].hand[0] = cost;
|
||||
const alternate = { ...f, g: other, cost };
|
||||
for (const id of [f.target, f.observer])
|
||||
assert.deepEqual(viewGame(f.g, id), viewGame(other, id));
|
||||
const a = applyAction(f.g, f.owner, nexusChoamRequest(f)),
|
||||
b = applyAction(other, f.owner, nexusChoamRequest(alternate));
|
||||
for (const id of [f.target, f.observer])
|
||||
assert.deepEqual(viewGame(a, id), viewGame(b, id));
|
||||
for (const g of [a, b])
|
||||
for (const id of [f.target, f.observer]) {
|
||||
assert.equal(Object.hasOwn(viewGame(g, id), 'nexusChoamHistory'), false);
|
||||
assert.equal(viewGame(g, id).choamWorthless, null);
|
||||
}
|
||||
});
|
||||
void test('CHOAM Nexus can discard a real Ghola as payment without activating its printed revival effect in Basic or Advanced', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = nexusChoamFixture('kulon', { advanced, costKind: 'ghola' });
|
||||
const before = f.g.players.map((p) => ({
|
||||
reserves: p.reserves,
|
||||
tanks: p.tanks,
|
||||
revived: p.revived,
|
||||
}));
|
||||
const g = nexusChoamAllow(applyAction(f.g, f.owner, nexusChoamRequest(f)));
|
||||
assert.deepEqual(
|
||||
g.players.map((p) => ({
|
||||
reserves: p.reserves,
|
||||
tanks: p.tanks,
|
||||
revived: p.revived,
|
||||
})),
|
||||
before,
|
||||
);
|
||||
assert.equal(g.discard.find((c) => c.id === f.cost.id)?.effect, 'ghola');
|
||||
assert.equal(g.choamMovement?.bonus, 1);
|
||||
nexusChoamInventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('CHOAM Nexus Baliset survives a real paid Nullentropy search and restores its original movement response before allow or cancellation', () => {
|
||||
for (const cancel of [false, true]) {
|
||||
const f = nexusChoamFixture('baliset', { opponentFaction: 'richese' });
|
||||
let g = f.g;
|
||||
const index = g.richeseCache!.findIndex(
|
||||
(card) => card.effect === 'nullentropyBox',
|
||||
);
|
||||
assert.ok(index >= 0);
|
||||
const box = g.richeseCache!.splice(index, 1)[0];
|
||||
g.players[1].hand.push(box);
|
||||
for (const kind of ['shield', 'projectile']) {
|
||||
const i = g.deck.findIndex((card) => card.kind === kind);
|
||||
assert.ok(i >= 0);
|
||||
g.discard.push(g.deck.splice(i, 1)[0]);
|
||||
}
|
||||
g = applyAction(g, f.owner, nexusChoamRequest(f));
|
||||
const response = structuredClone(g.response),
|
||||
move = structuredClone(g.pendingChoamMove);
|
||||
g = applyAction(g, f.target, { type: 'card', card: box.id });
|
||||
assert.equal(g.decision?.kind, 'nullentropy');
|
||||
assert.deepEqual(g.pendingNullentropy!.resume.response, response);
|
||||
assert.deepEqual(g.pendingChoamMove, move);
|
||||
assert.equal(g.players[1].spice, 18);
|
||||
nexusChoamInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(nexusChoamReload(g), p.id), viewGame(g, p.id));
|
||||
const selected = g.discard[0].id;
|
||||
g = applyAction(nexusChoamReload(g), f.target, {
|
||||
type: 'decision',
|
||||
event: g.pendingNullentropy!.event,
|
||||
card: selected,
|
||||
});
|
||||
assert.deepEqual(g.response, response);
|
||||
g = cancel
|
||||
? applyAction(g, f.target, {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama,
|
||||
})
|
||||
: nexusChoamAllow(g);
|
||||
assert.equal(g.nexusChoamLast?.stage, cancel ? 'canceled' : 'complete');
|
||||
assert.equal(g.players[1].forces['red_chasm:7'], cancel ? 1 : 3);
|
||||
assert.equal(g.players[1].spice, 18);
|
||||
assert.equal(g.discard.filter((card) => card.id === box.id).length, 1);
|
||||
assert.ok(g.players[1].hand.some((card) => card.id === selected));
|
||||
nexusChoamInventory(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('CHOAM Nexus legitimately fizzles after native Karama cash-in spends its payment, resuming movement or storm once', () => {
|
||||
for (const effect of ['baliset', 'jubba'] as const) {
|
||||
const f = nexusChoamFixture(effect);
|
||||
const karama = holdNexusChoamCard(f.g, f.owner, 'karama');
|
||||
let g = applyAction(f.g, f.owner, nexusChoamRequest(f));
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: karama.id,
|
||||
cards: [f.cost.id],
|
||||
});
|
||||
g = nexusChoamAllow(nexusChoamReload(g));
|
||||
assert.equal(g.nexusChoamLast?.stage, 'fizzled');
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((card) => card === 'choam').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(g.discard.filter((card) => card.id === f.cost.id).length, 1);
|
||||
assert.equal(g.discard.filter((card) => card.id === karama.id).length, 1);
|
||||
assert.equal(g.players[0].spice, 23);
|
||||
if (effect === 'baliset') {
|
||||
assert.equal(g.players[1].forces['red_chasm:7'], 1);
|
||||
assert.equal(g.players[1].moved, 1);
|
||||
} else {
|
||||
assert.equal(g.decision?.kind, 'choamStorm');
|
||||
g = applyAction(g, f.owner, { type: 'decision', decline: true });
|
||||
assert.equal(g.players[0].tanks, 4);
|
||||
assert.equal(g.players[1].tanks, 3);
|
||||
}
|
||||
reject(g, f.owner, nexusChoamRequest(f));
|
||||
nexusChoamInventory(g);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
NEXUS_CHOAM_EFFECTS,
|
||||
nexusChoamFixture,
|
||||
nexusChoamRequest,
|
||||
nexusChoamInventory,
|
||||
type NexusChoamEffect,
|
||||
} from './fixture-nexus-choam';
|
||||
import { applyAction, viewGame, type Game, type Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture(effect: NexusChoamEffect) {
|
||||
const store = unitStore(),
|
||||
made = await store.rooms.createRoom('CHOAM SQL', 'harkonnen', false, []),
|
||||
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 auths = await Promise.all(
|
||||
tokens.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
|
||||
const f = nexusChoamFixture(effect, { seatIds: ids });
|
||||
f.g.code = code;
|
||||
f.g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(f.g), f.g.version, code);
|
||||
store.writes.length = 0;
|
||||
return { ...store, ...f, code, tokens, auths, ids };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
const row = (f: Fixture) =>
|
||||
f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(f.code);
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f),
|
||||
rooms = f.restart();
|
||||
for (const [i, token] of f.tokens.entries()) {
|
||||
const auth = await rooms.authenticate(f.code, token),
|
||||
v = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(v, viewGame(g, f.ids[i]));
|
||||
assert.equal(Object.hasOwn(v, 'nexusChoamHistory'), false);
|
||||
if (i !== 0) assert.equal(v.choamWorthless, null);
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
nexusChoamInventory(g);
|
||||
}
|
||||
async function act(f: Fixture, seat: number, action: Action) {
|
||||
const g = await f.restart().readRoom(f.code);
|
||||
await f.restart().act(f.code, f.auths[seat], g.version, action, clock);
|
||||
return f.restart().readRoom(f.code);
|
||||
}
|
||||
void test('SQLite all five CHOAM Nexus effects recover private pending payment and settle each allowed/canceled parent once', async () => {
|
||||
for (const effect of NEXUS_CHOAM_EFFECTS)
|
||||
for (const cancel of [false, true]) {
|
||||
const f = await fixture(effect);
|
||||
try {
|
||||
// Drop the successful response, then recover only through a new production module.
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
f.g.version,
|
||||
nexusChoamRequest(f),
|
||||
clock,
|
||||
);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.nexusChoamLast?.stage, 'pending');
|
||||
assert.deepEqual(
|
||||
pending.players[0].hand.find((c) => c.id === f.cost.id),
|
||||
f.cost,
|
||||
);
|
||||
await restored(f, pending);
|
||||
const decision: Action = cancel
|
||||
? { type: 'card', mode: 'cancel', card: f.karama }
|
||||
: { type: 'passResponse' };
|
||||
const expected = applyAction(pending, f.target, decision);
|
||||
const done = await act(f, 1, decision);
|
||||
expected.version = done.version;
|
||||
assert.deepEqual(done, JSON.parse(JSON.stringify(expected)));
|
||||
assert.equal(
|
||||
done.nexusChoamLast?.stage,
|
||||
cancel ? 'canceled' : 'complete',
|
||||
);
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === f.cost.id).length,
|
||||
cancel ? 0 : 1,
|
||||
);
|
||||
assert.equal(
|
||||
done.nexusCards!.cards!.discard.filter((card) => card === 'choam')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
await assert.rejects(
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.auths[0],
|
||||
done.version,
|
||||
nexusChoamRequest(f),
|
||||
clock,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
void test('SQLite simultaneous last-holder pass and printed Karama settle one CHOAM Nexus outcome and preserve the other request as stale', async () => {
|
||||
for (const effect of NEXUS_CHOAM_EFFECTS) {
|
||||
const f = await fixture(effect);
|
||||
try {
|
||||
const pending = await act(f, 0, nexusChoamRequest(f));
|
||||
f.writes.length = 0;
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
const results = await Promise.allSettled(
|
||||
[
|
||||
{ type: 'passResponse' },
|
||||
{ type: 'card', mode: 'cancel', card: f.karama },
|
||||
].map((action) =>
|
||||
f
|
||||
.restart()
|
||||
.act(f.code, f.auths[1], pending.version, action as Action, clock),
|
||||
),
|
||||
);
|
||||
clearTimeout(timer);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, pending.version + 1);
|
||||
assert.equal(done.nexusChoamHistory!.length, 1);
|
||||
const canceled = done.nexusChoamLast!.stage === 'canceled';
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === f.cost.id).length,
|
||||
canceled ? 0 : 1,
|
||||
);
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === f.karama).length,
|
||||
canceled ? 1 : 0,
|
||||
);
|
||||
await restored(f, done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
void test('SQLite damaged CHOAM Nexus payment, virtual effect, parent, turn or completion proof is rejected without a write', async () => {
|
||||
const f = await fixture('baliset');
|
||||
try {
|
||||
const pending = await act(f, 0, nexusChoamRequest(f));
|
||||
const corruptions: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
g.pendingChoamWorthless!.card = 'unknown';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingChoamWorthless!.effect = 'kulon';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingChoamWorthless!.nexusEvent = 'stale';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingChoamMove!.total = 1;
|
||||
g.pendingChoamMove!.group[0][1] = 1;
|
||||
},
|
||||
(g) => {
|
||||
g.turn++;
|
||||
},
|
||||
(g) => {
|
||||
g.nexusChoamHistory![0].stage = 'complete';
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusChoamLast;
|
||||
},
|
||||
(g) => {
|
||||
delete g.nexusChoamHistory;
|
||||
},
|
||||
(g) => {
|
||||
g.response = null;
|
||||
},
|
||||
];
|
||||
for (const [index, corrupt] of corruptions.entries()) {
|
||||
const bad = structuredClone(pending);
|
||||
corrupt(bad);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(bad), bad.version, f.code);
|
||||
const before = row(f);
|
||||
f.writes.length = 0;
|
||||
for (const action of [
|
||||
{ type: 'passResponse' },
|
||||
{ type: 'card', mode: 'cancel', card: f.karama },
|
||||
] as Action[]) {
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[1], bad.version, action, clock),
|
||||
`corruption ${index}`,
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
}
|
||||
for (const auth of f.auths)
|
||||
await assert.rejects(
|
||||
f.restart().readSeatView(f.code, auth),
|
||||
`read corruption ${index}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CHOAM_NEXUS_EFFECTS,
|
||||
createNexusChoam,
|
||||
validateNexusChoam,
|
||||
type NexusChoamContext,
|
||||
type NexusChoamEffect,
|
||||
type NexusChoamReceipt,
|
||||
} from '../game/nexus-choam';
|
||||
|
||||
const context: NexusChoamContext = {
|
||||
turn: 3,
|
||||
players: [
|
||||
{ id: 'c', faction: 'choam' },
|
||||
{ id: 'a', faction: 'atreides' },
|
||||
{ id: 'h', faction: 'harkonnen' },
|
||||
],
|
||||
};
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
|
||||
void test('CHOAM Nexus records all six printed effects with their distinct phase windows', () => {
|
||||
assert.deepEqual(
|
||||
Object.values(CHOAM_NEXUS_EFFECTS).sort(),
|
||||
[
|
||||
'Baliset',
|
||||
'Jubba Cloak',
|
||||
'Kull Wahad',
|
||||
'Kulon',
|
||||
'La La La',
|
||||
'Trip to Gamont',
|
||||
].sort(),
|
||||
);
|
||||
for (const [effect, phase] of [
|
||||
['kulon', 5],
|
||||
['laLaLa', 4],
|
||||
['gamont', 8],
|
||||
['baliset', 5],
|
||||
['jubba', 0],
|
||||
] as const) {
|
||||
const receipt = createNexusChoam(
|
||||
context,
|
||||
'c',
|
||||
phase,
|
||||
'physical-poison-1',
|
||||
effect,
|
||||
);
|
||||
validateNexusChoam(context, clone(receipt));
|
||||
for (let wrong = 0; wrong <= 8; wrong++)
|
||||
if (wrong !== phase)
|
||||
assert.throws(
|
||||
() =>
|
||||
createNexusChoam(context, 'c', wrong, 'physical-poison-1', effect),
|
||||
/timing/,
|
||||
);
|
||||
}
|
||||
for (let phase = 0; phase <= 8; phase++)
|
||||
validateNexusChoam(
|
||||
context,
|
||||
createNexusChoam(context, 'c', phase, 'physical-shield-1', 'kull'),
|
||||
);
|
||||
});
|
||||
|
||||
void test('CHOAM historical receipt survives later turns and roster ordering without inspecting private state', () => {
|
||||
const receipt = createNexusChoam(
|
||||
context,
|
||||
'c',
|
||||
5,
|
||||
'physical-poison-1',
|
||||
'kulon',
|
||||
);
|
||||
const before = clone(receipt);
|
||||
const later = {
|
||||
turn: 4,
|
||||
players: [...context.players].reverse().map((p) =>
|
||||
Object.defineProperties(
|
||||
{ ...p },
|
||||
{
|
||||
hand: {
|
||||
get() {
|
||||
throw new Error('private hand read');
|
||||
},
|
||||
},
|
||||
ally: {
|
||||
get() {
|
||||
throw new Error('historical alliance read');
|
||||
},
|
||||
},
|
||||
spice: {
|
||||
get() {
|
||||
throw new Error('private balance read');
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
validateNexusChoam(later, clone(receipt));
|
||||
assert.deepEqual(receipt, before);
|
||||
const second = createNexusChoam(
|
||||
context,
|
||||
'c',
|
||||
5,
|
||||
'physical-shield-1',
|
||||
'kulon',
|
||||
);
|
||||
assert.notEqual(receipt.event, second.event);
|
||||
assert.notEqual(
|
||||
receipt.event,
|
||||
createNexusChoam(context, 'c', 5, receipt.card, 'baliset').event,
|
||||
);
|
||||
});
|
||||
|
||||
void test('CHOAM receipt rejects changed target, effect, turn, roster or identity without changing evidence', () => {
|
||||
const receipt = createNexusChoam(
|
||||
context,
|
||||
'c',
|
||||
5,
|
||||
'physical-poison-1',
|
||||
'kulon',
|
||||
);
|
||||
const edits: ((r: NexusChoamReceipt) => void)[] = [
|
||||
(r) => {
|
||||
r.card = 'other';
|
||||
},
|
||||
(r) => {
|
||||
r.effect = 'baliset';
|
||||
},
|
||||
(r) => {
|
||||
r.turn++;
|
||||
},
|
||||
(r) => {
|
||||
r.owner = 'a';
|
||||
},
|
||||
(r) => {
|
||||
r.event = 'other';
|
||||
},
|
||||
(r) => {
|
||||
r.phase = 8;
|
||||
},
|
||||
(r) => {
|
||||
r.roster.pop();
|
||||
},
|
||||
(r) => {
|
||||
r.roster[0].faction = 'guild';
|
||||
},
|
||||
(r) => {
|
||||
r.signature = 'changed';
|
||||
},
|
||||
(r) => {
|
||||
delete (r as Partial<NexusChoamReceipt>).card;
|
||||
},
|
||||
(r) => {
|
||||
Object.assign(r, { stage: 'complete' });
|
||||
},
|
||||
];
|
||||
for (const edit of edits) {
|
||||
const bad = clone(receipt);
|
||||
edit(bad);
|
||||
const before = clone(bad);
|
||||
assert.throws(() => validateNexusChoam(context, bad), /CHOAM Nexus/);
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
assert.throws(
|
||||
() => validateNexusChoam({ ...context, turn: 2 }, receipt),
|
||||
/CHOAM Nexus/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateNexusChoam(
|
||||
{
|
||||
...context,
|
||||
players: context.players.map((p) =>
|
||||
p.id === 'c' ? { ...p, faction: 'guild' } : p,
|
||||
),
|
||||
},
|
||||
receipt,
|
||||
),
|
||||
/CHOAM Nexus/,
|
||||
);
|
||||
});
|
||||
|
||||
void test('CHOAM receipt creation rejects malformed or foreign inputs before producing a grant', () => {
|
||||
const before = clone(context);
|
||||
for (const owner of ['a', 'missing', '__proto__'])
|
||||
assert.throws(
|
||||
() => createNexusChoam(context, owner, 5, 'card', 'kulon'),
|
||||
/CHOAM Nexus/,
|
||||
);
|
||||
for (const phase of [-1, 9, 1.5, NaN])
|
||||
assert.throws(
|
||||
() => createNexusChoam(context, 'c', phase, 'card', 'kull'),
|
||||
/CHOAM Nexus/,
|
||||
);
|
||||
for (const card of ['', ' ', '__proto__'])
|
||||
assert.throws(
|
||||
() => createNexusChoam(context, 'c', 5, card, 'kulon'),
|
||||
/CHOAM Nexus/,
|
||||
);
|
||||
for (const effect of ['unknown', 'constructor', '__proto__'])
|
||||
assert.throws(
|
||||
() =>
|
||||
createNexusChoam(context, 'c', 5, 'card', effect as NexusChoamEffect),
|
||||
/CHOAM Nexus/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createNexusChoam(
|
||||
{ ...context, players: [...context.players, context.players[0]] },
|
||||
'c',
|
||||
5,
|
||||
'card',
|
||||
'kulon',
|
||||
),
|
||||
/roster/,
|
||||
);
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
Reference in new issue
Block a user