Implement five CHOAM Nexus Cunning effects with private costs and recovery

This commit is contained in:
Quintine committed 2026-09-10 12:16:39 +10:00
1 parent a6d9235099
commit 290259bac2
24 files changed
+1935 -131

No files matched your search

+228
View File
@@ -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;
+2 -8
View File
@@ -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) {
+2 -2
View File
@@ -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/);
+55
View File
@@ -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);
}
});
}
+74
View File
@@ -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,
});
});
+142
View File
@@ -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);
}
});
+269
View File
@@ -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);
}
});
+229
View File
@@ -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();
}
});
+218
View File
@@ -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);
});