Integrate CHOAM Nexus Cunning Kull with source-aware costs

This commit is contained in:
Quintine committed 2026-10-01 06:06:03 +10:00
1 parent fc5cbcd720
commit 0d9d0a72d6
32 files changed
+2502 -193

No files matched your search

+4 -4
View File
@@ -24,7 +24,7 @@ void test('every profile uses the projected physical Kull and cannot act from a
}
}
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'kullDecision', event: own.kullReaction!.event, card: 'ix-kull-wahad' }]);
assert.deepEqual(actions, [{ type: 'kullDecision', event: own.kullReaction!.event, source: 'printed', card: 'ix-kull-wahad' }]);
assert.deepEqual(botActions(viewGame(saved, 'h')), []);
let settled = applyAction(saved, 'c', actions[0]);
// A non-target declines the native response with its own legal card held.
@@ -74,7 +74,7 @@ void test('every profile counters Kull only with a distinct projected card and r
game.players[1].hand.push(counter);
const offer = applyAction(game, 'e', kullShipmentAttempt(game));
const declared = applyAction(offer, 'c', {
type: 'kullDecision', event: offer.pendingKull!.event, card: 'ix-kull-wahad',
type: 'kullDecision', event: offer.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad',
});
const own = viewGame(JSON.parse(JSON.stringify(declared)), 'e');
assert.ok(!own.responseControls!.cancelCards.includes(reserved));
@@ -97,7 +97,7 @@ void test('a blocked projected cost is declined rather than treated as a playabl
const offer = applyAction(game, 'e', kullShipmentAttempt(game));
const own = viewGame(offer, 'c');
own.kullReaction!.blocked = 'The cost is reserved for another continuation.';
Object.defineProperty(own.kullReaction, 'cards', {
Object.defineProperty(own.kullReaction, 'plays', {
get() { throw new Error('Read ineligible cost choices'); },
});
const action = botActions(own)[0];
@@ -118,7 +118,7 @@ void test('BG counter conversion keeps all profiles in standard response control
const counter = game.players[2].hand[0].id;
const offer = applyAction(game, 'e', kullShipmentAttempt(game));
const declared = applyAction(offer, 'c', {
type: 'kullDecision', event: offer.pendingKull!.event, card: 'ix-kull-wahad',
type: 'kullDecision', event: offer.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad',
});
const action = botActions(viewGame(declared, 'b'))[0];
assert.deepEqual(action, { type: 'card', mode: 'cancel', card: counter });
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { choamPowerAction } from '../game/choam-power-options';
import { createNexusChoamKullFixture } from './fixture-nexus-choam-kull';
void test('all four profiles settle saved Basic and Advanced Nexus Kull with only their own projected physical fuel', () => {
for (const profile of DIFFICULTIES) {
for (const advanced of [false, true]) {
for (const fuel of ['karama', 'worthless', 'weapon'] as const) {
const fixture = createNexusChoamKullFixture({ advanced, fuel, counter: false });
const saved: Game = JSON.parse(JSON.stringify(fixture.game));
saved.players.find(player => player.id === fixture.choam)!.bot = profile;
saved.players.find(player => player.id === fixture.other)!.bot = profile;
const own = viewGame(saved, fixture.choam);
for (const player of own.players) {
if (player.id === own.me) continue;
for (const key of ['hand', 'traitors', 'spice']) {
Object.defineProperty(player, key, { get() { throw new Error(`Read rival ${key}`); } });
}
}
const actions = botActions(own);
assert.equal(actions.length, 1);
assert.deepEqual(botActions(viewGame(saved, fixture.other!)), []);
const settled = applyAction(saved, fixture.choam, actions[0]);
assert.equal(settled.pendingKull, null);
assert.equal(settled.karamaShipping, null);
assert.equal(settled.discard.filter(card => card.id === fixture.fuel).length, 1);
assert.ok(settled.players.find(player => player.id === fixture.actor)!.hand.some(card => card.id === fixture.original));
assert.ok(viewGame(settled, fixture.actor).karamaBlocked);
assert.equal(settled.nexusCards!.cards!.hands[fixture.choam], null);
assert.equal(settled.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1);
assert.equal(settled.nexusChoamHistory!.at(-1)!.stage, 'complete');
}
}
}
});
void test('all profiles decline unavailable Nexus costs rather than falling through to proactive powers or consuming fuel', () => {
for (const profile of DIFFICULTIES) {
const fixture = createNexusChoamKullFixture({ nexusFace: 'richese', counter: false });
fixture.game.players.find(player => player.id === fixture.choam)!.bot = profile;
const actions = botActions(viewGame(fixture.game, fixture.choam));
assert.equal(actions.length, 1);
const resumed = applyAction(fixture.game, fixture.choam, actions[0]);
assert.equal(resumed.pendingKull, null);
assert.equal(resumed.karamaShipping?.owner, fixture.actor);
assert.equal(resumed.discard.filter(card => card.id === fixture.original).length, 1);
assert.ok(resumed.players.find(player => player.id === fixture.choam)!.hand.some(card => card.id === fixture.fuel));
assert.equal(resumed.nexusCards!.cards!.hands[fixture.choam], 'richese');
assert.equal(resumed.nexusChoamHistory, undefined);
}
});
void test('every profile can prevent Nexus Kull for an ally using a distinct legal counter, retaining fuel but not refunding Nexus', () => {
for (const profile of DIFFICULTIES) {
const fixture = createNexusChoamKullFixture({ fuel: 'weapon' });
const other = fixture.game.players.find(player => player.id === fixture.other)!;
const actor = fixture.game.players.find(player => player.id === fixture.actor)!;
other.bot = profile;
other.ally = actor.id;
actor.ally = other.id;
const declared = applyAction(fixture.game, fixture.choam, {
type: 'kullDecision', event: fixture.event, source: 'nexus', card: fixture.fuel,
});
const actions = botActions(viewGame(JSON.parse(JSON.stringify(declared)), other.id));
assert.equal(actions.length, 1);
const resumed = applyAction(declared, other.id, actions[0]);
assert.equal(resumed.pendingKull, null);
assert.equal(resumed.karamaShipping?.owner, actor.id);
assert.equal(resumed.discard.filter(card => card.id === fixture.counter).length, 1);
assert.equal(resumed.discard.filter(card => card.id === fixture.original).length, 1);
assert.ok(resumed.players.find(player => player.id === fixture.choam)!.hand.some(card => card.id === fixture.fuel));
assert.equal(viewGame(resumed, actor.id).karamaBlocked, null);
assert.equal(resumed.nexusCards!.cards!.hands[fixture.choam], null);
assert.equal(resumed.nexusChoamHistory!.at(-1)!.stage, 'canceled');
}
});
void test('all profiles leave the interrupted BG Worthless card unconverted when Nexus Kull succeeds', () => {
for (const profile of DIFFICULTIES) {
const fixture = createNexusChoamKullFixture({ advanced: true, bg: true, fuel: 'weapon', counter: false });
fixture.game.players.find(player => player.id === fixture.choam)!.autopilot = profile;
const action = botActions(viewGame(JSON.parse(JSON.stringify(fixture.game)), fixture.choam))[0];
assert.ok(action);
const settled = applyAction(fixture.game, fixture.choam, action);
assert.equal(settled.pendingKull, null);
const original = settled.players.find(player => player.id === fixture.actor)!.hand.find(card => card.id === fixture.original);
assert.equal(original?.kind, 'worthless');
assert.ok(!settled.discard.some(card => card.id === fixture.original));
assert.ok(viewGame(settled, fixture.actor).karamaBlocked);
}
});
void test('a blocked projected Nexus cost is declined and cannot be submitted through the generic proactive CHOAM path', () => {
for (const profile of DIFFICULTIES) {
const fixture = createNexusChoamKullFixture({ fuel: 'weapon', counter: false });
fixture.game.players.find(player => player.id === fixture.choam)!.bot = profile;
const own = viewGame(fixture.game, fixture.choam);
const play = own.kullReaction!.plays[0];
assert.equal(choamPowerAction(own, play), null);
play.blocked = 'Reserved by another commitment.';
const action = botActions(own)[0];
assert.ok(action);
const resumed = applyAction(fixture.game, fixture.choam, action);
assert.equal(resumed.pendingKull, null);
assert.equal(resumed.karamaShipping?.owner, fixture.actor);
assert.ok(resumed.players.find(player => player.id === fixture.choam)!.hand.some(card => card.id === fixture.fuel));
assert.equal(resumed.nexusCards!.cards!.hands[fixture.choam], 'choam');
}
});
+5 -5
View File
@@ -4,7 +4,8 @@ import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { applyAction, viewGame } from '../game/engine';
import type { GameView } from '../game/engine';
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
const aliases = registerHooks({
@@ -43,7 +44,7 @@ void test('only the reactor receives physical Kull cost controls; outsiders cann
}
const html = markup(holder);
assert.match(html, /<button[^>]*>Decline Kull/);
assert.match(html, /<button[^>]*>Use Kull Wahad \(ix-kull-wahad\)/);
assert.match(html, /<button[^>]*>Use Kull Wahad[^<]*ix-kull-wahad/);
assert.match(html, /Inspect card/);
const outsider = markup(rival);
assert.doesNotMatch(outsider, /<button|ix-kull-wahad|Inspect card/);
@@ -61,7 +62,7 @@ void test('busy and blocked offers cannot expose an enabled use action, but an u
assert.doesNotMatch(markup(holder), />Use Kull Wahad/);
assert.match(markup(holder), /<button[^>]*>Decline Kull/);
holder.kullReaction!.blocked = null;
holder.kullReaction!.cards = [];
holder.kullReaction!.plays = [];
assert.doesNotMatch(markup(holder), />Use Kull Wahad/);
assert.match(markup(holder), /<button[^>]*>Decline Kull/);
holder.kullReaction!.canDecline = false;
@@ -75,7 +76,6 @@ void test('the real saved table mounts the offer instead of shipment and volunta
}));
assert.match(html, /aria-label="Kull Wahad response"/);
assert.match(html, /<button[^>]*>Use Kull Wahad/);
assert.match(html, /href="\/rules\?topic=choam-kull#choam-kull"/);
assert.doesNotMatch(html, /<button[^>]*>[^<]*(?:Ship forces|Cash in|Play Karama|End movement)/i);
const disabled = renderToStaticMarkup(createElement(GameTable, {
game: holder, send: async () => {}, busy: true, onExit() {},
@@ -87,7 +87,7 @@ void test('a successful phase ban removes the retained Karama shipment-rate cont
const initial = choamKullGame();
let g = applyAction(initial, 'e', kullShipmentAttempt(initial));
g = applyAction(g, 'c', {
type: 'kullDecision', event: g.pendingKull!.event, card: 'ix-kull-wahad',
type: 'kullDecision', event: g.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad',
});
for (let step = 0; g.pendingKull && step < 12; step++) {
const actor = g.players.find(p => {
+5 -5
View File
@@ -16,7 +16,7 @@ function inventory(g: Game) {
}
function declare(g: Game) {
const reaction = viewGame(g, 'c').kullReaction!;
return applyAction(reload(g), 'c', { type: 'kullDecision', event: reaction.event, card: 'ix-kull-wahad' });
return applyAction(reload(g), 'c', { type: 'kullDecision', event: reaction.event, source: 'printed', card: 'ix-kull-wahad' });
}
function finishKull(g: Game) {
for (let step = 0; g.pendingKull && step < 12; step++) {
@@ -62,11 +62,11 @@ void test('ordinary printed attempts offer neutral CHOAM timing before cost with
assert.ok(held(offered, 'e', original));
assert.equal(offered.discard.some(card => card.id === original), false);
for (const id of ['e', 'b', 'h']) {
assert.deepEqual(viewGame(offered, id).kullReaction?.cards, []);
assert.deepEqual(viewGame(offered, id).kullReaction?.plays, []);
assert.equal(viewGame(offered, id).kullReaction?.canDecline, false);
assert.equal(viewGame(offered, id).response, null);
}
assert.deepEqual(viewGame(offered, 'c').kullReaction?.cards.map(card => card.id), ['ix-kull-wahad']);
assert.deepEqual(viewGame(offered, 'c').kullReaction?.plays.map(play => [play.source, play.card.id]), [['printed', 'ix-kull-wahad']]);
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
const event = offered.pendingKull!.event;
for (const [id, bad] of [
@@ -570,11 +570,11 @@ void test('CHOAM cannot voluntarily spend the Kull card required by its own batt
}];
g.response = { kind: 'eliteStrength', owner: 'e', passed: [] };
const offered = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
assert.deepEqual(viewGame(offered, 'c').kullReaction?.cards, []);
assert.deepEqual(viewGame(offered, 'c').kullReaction?.plays, []);
assert.equal(viewGame(offered, 'c').kullReaction?.canDecline, true);
const before = JSON.stringify(offered);
assert.throws(() => applyAction(offered, 'c', {
type: 'kullDecision', event: offered.pendingKull!.event, card: 'ix-kull-wahad',
type: 'kullDecision', event: offered.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad',
}), /eligible held Kull/);
assert.equal(JSON.stringify(offered), before);
const declined = applyAction(offered, 'c', {
+120 -14
View File
@@ -40,9 +40,13 @@ async function persisted(prepare?: (g: Game) => void) {
prepare?.(initial);
assert.equal(store.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
.run(JSON.stringify(initial), initial.version, code, current.version).changes, 1);
const unrelated = await store.rooms.createRoom('Preserved unrelated room', 'atreides', false, []);
const unrelatedCode = unrelated.view.code;
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(unrelatedCode);
store.writes.length = 0;
return { ...store, code, tokens, auths, initial,
originalCredentials: credentials(store.sqlite), originalCards: inventory(initial) };
originalCredentials: credentials(store.sqlite), originalCards: inventory(initial),
unrelatedCode, unrelatedRow };
} catch (error) {
store.sqlite.close();
throw error;
@@ -60,11 +64,17 @@ type Fixture = {
initial: Game;
originalCredentials: { seats: unknown[]; entries: unknown[]; keys: unknown[]; recoveries: unknown[] };
originalCards: string[];
unrelatedCode: string;
unrelatedRow: Record<string, unknown> | undefined;
};
async function act(f: Fixture, g: Game, actor: string, action: Action) {
const rooms = f.restart();
const auth = f.auths.find(auth => auth.playerId === actor)!;
const index = f.auths.findIndex(auth => auth.playerId === actor);
assert.ok(index >= 0);
const auth = await rooms.authenticate(f.code, f.tokens[index]);
assert.equal(auth.playerId, actor);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
await rooms.act(f.code, auth, g.version, action, clock);
return rooms.readRoom(f.code);
}
@@ -74,6 +84,8 @@ async function restored(f: Fixture, g: Game) {
const views: GameView[] = [];
for (let index = 0; index < f.tokens.length; index++) {
const auth = await rooms.authenticate(f.code, f.tokens[index]);
assert.equal(auth.playerId, f.auths[index].playerId);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(g, auth.playerId));
if (g.pendingKull) {
@@ -88,6 +100,7 @@ async function restored(f: Fixture, g: Game) {
views.push(view);
}
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow);
assert.deepEqual(inventory(g), f.originalCards);
assert.equal(new Set(inventory(g)).size, f.originalCards.length);
return views;
@@ -104,6 +117,7 @@ async function rejected(f: Fixture, g: Game, actor: string, action: Action, vers
assert.deepEqual(attemptedStatements, [], 'rejected game actions must not attempt SQL writes');
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.unrelatedCode), f.unrelatedRow);
}
async function stationary(f: Fixture, g: Game) {
@@ -158,9 +172,10 @@ async function offer(f: Fixture, initial: Game, actor: string, action: Action, e
assert.equal(reaction.player, f.auths[0].playerId);
assert.equal(reaction.target, actor);
assert.equal(reaction.canDecline, true);
assert.deepEqual(reaction.cards.map(card => card.id), expectedCards);
assert.deepEqual(reaction.plays.map(play => ({ source: play.source, card: play.card.id })),
expectedCards.map(card => ({ source: 'printed', card })));
const publicFields = { event: reaction.event, player: reaction.player, target: reaction.target,
intent: reaction.intent, cards: [], canDecline: false, blocked: null };
intent: reaction.intent, plays: [], canDecline: false, blocked: null };
for (const view of views.slice(1)) assert.deepEqual(view.kullReaction, publicFields);
assert.deepEqual(g.players, initial.players, 'the public offer must precede every card, revival, income and once-use cost');
assert.deepEqual(g.discard, initial.discard);
@@ -183,7 +198,7 @@ void test('persisted Kull offer and racing decline revive Emperor forces exactly
const decline = { type: 'kullDecision', event, decline: true };
await rejected(f, g, emperor, decline);
await rejected(f, g, f.auths[0].playerId, { ...decline, event: `${event}-obsolete` });
await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, card: card.id });
await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, source: 'printed', card: card.id });
await rejected(f, g, f.auths[0].playerId, { type: 'kullDecision', event, decline: 'yes' });
await rejected(f, g, f.auths[0].playerId, decline, g.version - 1);
const done = await compete(f, g, f.auths[0].playerId, decline);
@@ -205,7 +220,7 @@ void test('persisted Kull success races the final pass, retains printed Karama a
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
const original = kullShipmentAttempt(f.initial, emperor);
const { g: offered, event } = await offer(f, f.initial, emperor, original);
let g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
let g = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
assert.equal(g.response?.kind, 'choamWorthless');
assert.equal(g.karamaShipping, null);
assert.equal(g.discard.some(card => card.id === original.card), false);
@@ -232,7 +247,7 @@ void test('persisted Kull success races the final pass, retains printed Karama a
assert.equal(player(done, emperor).hand.filter(card => card.id === original.card).length, 1);
assert.equal(player(done, emperor).spice, player(f.initial, emperor).spice);
await rejected(f, done, emperor, original);
await rejected(f, done, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
await rejected(f, done, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
await stationary(f, done);
} finally { f.sqlite.close(); }
});
@@ -247,7 +262,7 @@ void test('a distinct held Karama counters persisted Kull and resumes the origin
const original = kullShipmentAttempt(f.initial, emperor);
const distinct = player(f.initial, emperor).hand.find(card => card.effect === 'karama' && card.id !== original.card)!;
const { g: offered, event } = await offer(f, f.initial, emperor, original);
const g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
const g = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
const views = await restored(f, g);
assert.deepEqual(views[1].responseControls?.cancelCards, [distinct.id]);
assert.equal(views[1].responseControls?.cancelCards.includes(String(original.card)), false);
@@ -279,7 +294,7 @@ void test('Kull intercepts persisted BG substitution before conversion and retai
const original = { type: 'card', mode: 'shipment', card: worthless[0].id, target: emperor };
const { g: offered, event } = await offer(f, f.initial, bg, original);
assert.equal(offered.pendingKarama, null);
let g = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
let g = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
assert.equal(g.response?.kind, 'choamWorthless');
assert.equal(g.pendingKarama, null);
assert.equal(player(g, bg).hand.some(card => card.id === worthless[0].id), true);
@@ -335,7 +350,7 @@ void test('successful persisted Kull prevents Emperor revival before forces or i
const karama = player(f.initial, emperor).hand.find(card => card.effect === 'karama')!;
const original = { type: 'card', mode: 'special', card: karama.id, amount: 2 };
const { g: offered, event } = await offer(f, f.initial, emperor, original);
const declared = await act(f, offered, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
const declared = await act(f, offered, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
await restored(f, declared);
const done = await passes(f, declared);
assert.deepEqual(player(done, emperor), player(f.initial, emperor));
@@ -354,7 +369,7 @@ void test('a Kull-free saved CHOAM hand still receives the neutral private offer
const original = kullShipmentAttempt(f.initial, emperor);
await rejected(f, f.initial, emperor, { type: 'card', mode: 'special', card: original.card, amount: 2 });
const { g, event } = await offer(f, f.initial, emperor, original, []);
await rejected(f, g, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
await rejected(f, g, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
const done = await act(f, g, choam, { type: 'kullDecision', event, decline: true });
assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card });
assert.equal(done.discard.filter(card => card.id === original.card).length, 1);
@@ -374,7 +389,7 @@ void test('corrupt saved Kull intent ownership cannot execute or write after a r
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
.run(JSON.stringify(corrupt), f.code, g.version).changes, 1);
await rejected(f, corrupt, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, corrupt, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
await assert.rejects(f.restart().readSeatView(f.code, f.auths[0]));
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
} finally { f.sqlite.close(); }
@@ -386,7 +401,7 @@ void test('orphaned saved Kull counters cannot be projected or commit a partial
try {
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
const { g, event } = await offer(f, f.initial, emperor, kullShipmentAttempt(f.initial, emperor));
let counter = await act(f, g, choam, { type: 'kullDecision', event, card: 'ix-kull-wahad' });
let counter = await act(f, g, choam, { type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
if (conversion) {
const bg = f.auths[2].playerId;
counter = await act(f, counter, bg, {
@@ -399,8 +414,99 @@ void test('orphaned saved Kull counters cannot be projected or commit a partial
.run(JSON.stringify(corrupt), f.code, counter.version).changes, 1);
await rejected(f, corrupt, f.auths[3].playerId, { type: 'passResponse' });
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, auth), /lost its interrupted Karama frame/);
await assert.rejects(f.restart().readSeatView(f.code, auth));
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
} finally { f.sqlite.close(); }
}
});
void test('a verified pre-upgrade printed Kull counter migrates without selecting a new cost or losing its exact suspended response', async () => {
const f = await persisted();
try {
const choam = f.auths[0].playerId, emperor = f.auths[1].playerId, harkonnen = f.auths[3].playerId;
const original = kullShipmentAttempt(f.initial, emperor);
const { g: offered, event } = await offer(f, f.initial, emperor, original);
const declared = await act(f, offered, choam,
{ type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
const legacy = structuredClone(declared);
const retained = JSON.parse(JSON.stringify({
response: legacy.response, resume: legacy.pendingKull!.resume,
declaration: legacy.pendingChoamWorthless, players: legacy.players, discard: legacy.discard,
}));
// The old producer signed these same controls before the source selection field existed.
const signature: Record<string, unknown> = JSON.parse(legacy.pendingKull!.signature);
delete signature.selection;
delete legacy.pendingKull!.selection;
legacy.pendingKull!.signature = JSON.stringify(signature);
const legacyJSON = JSON.stringify(legacy);
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
.run(legacyJSON, f.code, legacy.version).changes, 1);
f.writes.length = 0;
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const old = await f.restart().readRoom(f.code);
assert.equal(old.pendingKull!.selection, undefined);
const oldViews = await restored(f, old);
assert.equal(oldViews[3].responseControls?.cancelCards.includes(
player(old, harkonnen).hand.find(card => card.effect === 'karama')!.id), true);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(f.writes.length, 0, 'reading the old save does not acknowledge its human counter');
await f.restart().continueRoomAutomatic(f.code, clock);
const migrated = await f.restart().readRoom(f.code);
assert.equal(migrated.version, legacy.version + 1, 'the bounded binding is persisted once');
assert.deepEqual(f.writes.map(write => write.changes), [1]);
assert.deepEqual(migrated.pendingKull!.selection, { source: 'printed', card: 'ix-kull-wahad' });
assert.equal(migrated.pendingKull!.event, event);
assert.deepEqual(JSON.parse(JSON.stringify({
response: migrated.response, resume: migrated.pendingKull!.resume,
declaration: migrated.pendingChoamWorthless, players: migrated.players, discard: migrated.discard,
})), retained);
await restored(f, migrated);
await stationary(f, migrated);
const counter = player(migrated, harkonnen).hand.find(card => card.effect === 'karama')!;
const done = await act(f, migrated, harkonnen, { type: 'card', mode: 'cancel', card: counter.id });
assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card });
assert.equal(done.pendingKull ?? null, null);
assert.equal(done.response, null);
assert.equal(done.discard.filter(card => card.id === original.card).length, 1);
assert.equal(done.discard.filter(card => card.id === counter.id).length, 1);
assert.equal(player(done, choam).hand.filter(card => card.id === 'ix-kull-wahad').length, 1);
assert.equal(done.discard.some(card => card.id === 'ix-kull-wahad'), false);
await restored(f, done);
await rejected(f, done, harkonnen, { type: 'card', mode: 'cancel', card: counter.id });
await stationary(f, done);
} finally { f.sqlite.close(); }
});
void test('an old printed counter with altered controls or an unverified signature cannot use the bounded save migration', async () => {
const f = await persisted();
try {
const choam = f.auths[0].playerId, emperor = f.auths[1].playerId;
const { g: offered, event } = await offer(f, f.initial, emperor, kullShipmentAttempt(f.initial, emperor));
const declared = await act(f, offered, choam,
{ type: 'kullDecision', event, source: 'printed', card: 'ix-kull-wahad' });
for (const change of [
(g: Game) => { g.pendingKull!.signature += 'forged'; },
(g: Game) => { g.pendingKull!.resume.decision = { kind: 'auctionPayment', player: emperor }; },
(g: Game) => { g.pendingChoamWorthless!.card = g.deck[0].id; },
]) {
const corrupt = structuredClone(declared);
const signature: Record<string, unknown> = JSON.parse(corrupt.pendingKull!.signature);
delete signature.selection;
delete corrupt.pendingKull!.selection;
corrupt.pendingKull!.signature = JSON.stringify(signature);
change(corrupt);
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
.run(JSON.stringify(corrupt), f.code, corrupt.version).changes, 1);
f.writes.length = 0;
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
for (const token of f.tokens) {
const rooms = f.restart();
await assert.rejects(rooms.readSeatView(f.code, await rooms.authenticate(f.code, token)));
}
await rejected(f, corrupt, emperor, { type: 'passResponse' });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code), before);
assert.equal(f.writes.length, 0);
}
} finally { f.sqlite.close(); }
});
+43
View File
@@ -9,6 +9,7 @@ import {
kullBlocksKarama,
kullCounterCards,
kullNativeCostCards,
kullNexusCostCards,
validateKullAttemptStamp,
validateKullPhaseRestrictions,
type KullAttemptStamp,
@@ -151,6 +152,48 @@ void test('native Kull costs require canonical identity and unique actual custod
assert.deepEqual({ held, physical }, before);
});
void test('Nexus Kull accepts canonical non-Worthless Treachery fuel without narrowing to printed Kull', () => {
const physical = [...baseDeck(), ...ixStandardCards()];
const weapon = physical.find(card => card.kind === 'projectile')!;
const karama = physical.find(card => card.effect === 'karama')!;
const worthless = physical.find(card => card.kind === 'worthless' && card.id !== 'ix-kull-wahad')!;
const kull = physical.find(card => card.id === 'ix-kull-wahad')!;
const held: Card[] = JSON.parse(JSON.stringify([weapon, karama, worthless, kull]));
const before = structuredClone({ held, physical });
assert.deepEqual(kullNexusCostCards(held, physical).map(card => card.id),
[weapon.id, karama.id, worthless.id, kull.id]);
assert.deepEqual(kullNativeCostCards(held, physical).map(card => card.id), [kull.id]);
assert.deepEqual(kullNexusCostCards(held, physical, [karama.id, kull.id, 'absent']).map(card => card.id),
[weapon.id, worthless.id]);
assert.deepEqual(kullNexusCostCards([], physical), []);
assert.deepEqual({ held, physical }, before);
});
void test('Nexus Kull excludes counterfeit, duplicated and reserved custody without losing unrelated real fuel', () => {
const physical = [...baseDeck(), ...ixStandardCards()];
const weapon = physical.find(card => card.kind === 'projectile')!;
const karama = physical.find(card => card.effect === 'karama')!;
const held = Object.freeze([Object.freeze({ ...weapon }), Object.freeze({ ...karama })]);
const before = structuredClone({ held, physical });
for (const fake of [
{ ...weapon, id: 'invented' },
{ ...weapon, id: '' },
{ ...weapon, name: 'counterfeit' },
{ ...weapon, kind: 'worthless' as const },
{ ...weapon, effect: 'karama' },
]) {
assert.deepEqual(kullNexusCostCards([fake, karama], physical).map(card => card.id), [karama.id]);
}
assert.deepEqual(kullNexusCostCards([weapon, { ...weapon }, karama], physical).map(card => card.id),
[karama.id]);
assert.deepEqual(kullNexusCostCards(held, [...physical, { ...weapon }]).map(card => card.id),
[karama.id]);
assert.deepEqual(kullNexusCostCards(held, physical.filter(card => card.id !== weapon.id)).map(card => card.id),
[karama.id]);
assert.deepEqual(kullNexusCostCards(held, physical, [weapon.id, karama.id]), []);
assert.deepEqual({ held, physical }, before);
});
void test('distinct counters honor effective BG and revealed Shrine eligibility without reusing the original', () => {
const physical = baseDeck();
const karamas = physical.filter(card => card.effect === 'karama');
+203
View File
@@ -0,0 +1,203 @@
import { applyAction, battles, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { takeKullCard } from './fixture-choam-kull';
import { orderNexusSpice } from './fixture-nexus-cards';
import type { FactionId } from '../game/catalog';
export function createNexusChoamKullFixture(options: {
advanced?: boolean; bg?: boolean; seatIds?: string[]; nexusFace?: 'choam' | 'richese';
actorFaction?: Extract<FactionId, 'atreides' | 'emperor' | 'beneGesserit' | 'guild' | 'fremen'>;
fuel?: 'karama' | 'worthless' | 'weapon'; counter?: boolean;
} = {}): { game: Game; choam: string; actor: string; other: string | null; event: string;
original: string; fuel: string; counter: string | null } {
const ids = options.seatIds ?? ['c', 'e', 'h'];
if (ids.length !== 3 || new Set(ids).size !== 3) throw new Error('Nexus Kull fixture requires three distinct seats.');
const [choam, actor, other] = ids;
const needsSubstitute = options.fuel === 'karama' && !options.bg;
const substitute = needsSubstitute && (options.advanced ?? true);
if (needsSubstitute && options.advanced === false && options.counter === true)
throw new Error('Basic has only two physical Karama cards: original and fuel leave no distinct counter.');
let game = createGame('NXKULL01', newPlayer(choam, 'CHOAM', 'choam'), options.advanced ?? true, ['choam', 'ix']);
joinGame(game, newPlayer(actor, 'Actor', options.actorFaction ?? (options.bg ? 'beneGesserit' : 'emperor')));
joinGame(game, newPlayer(other, 'Other', substitute ? 'beneGesserit' : 'harkonnen'));
for (const p of game.players) { p.bot = 'Medium'; p.ready = true; }
game.nexusCards = { cards: null, phase: null };
game = initializeNexusKullGameForAudit(game);
for (let step = 0; game.status === 'setup' && step < 100; step++) {
let next: Game | undefined;
for (const p of game.players) {
const action = botActions(viewGame(game, p.id))[0];
if (action) { next = applyAction(game, p.id, action); break; }
}
if (!next) throw new Error('Genuine Nexus Kull setup has no legal action.');
game = next;
}
if (game.status !== 'playing') throw new Error('Genuine Nexus Kull setup did not complete.');
for (const phase of [0, 1]) {
if (phase === 1) orderNexusSpice(game, ['land', 'land']);
for (let step = 0; game.phase === phase && step < 80; step++) {
let next: Game | undefined;
for (const p of game.players) {
const view = viewGame(game, p.id);
view.players.find(seat => seat.id === p.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = applyAction(game, p.id, action); break; }
}
if (!next) throw new Error('Genuine CHOAM/Ix Nexus phase has no legal continuation.');
game = next;
}
if (game.phase !== phase + 1) throw new Error('Genuine CHOAM/Ix Nexus phase did not finish.');
}
game.deck.push(...game.players.flatMap(p => p.hand));
for (const p of game.players) { p.hand = []; delete p.bot; }
const original = takeKullCard(game, options.bg ? 'Baliset' : 'karama');
game.players[1].hand.push(original);
const fuel = takeKullCard(game, options.fuel === 'karama' ? 'karama' : options.fuel === 'weapon' ? 'ix-hunter-seeker' : 'La La La');
game.players[0].hand.push(fuel);
const counter = options.counter === false || (needsSubstitute && !substitute)
? null : takeKullCard(game, substitute ? 'Jubba Cloak' : 'karama');
if (counter) game.players[2].hand.push(counter);
const cards = game.nexusCards!.cards!;
const face = options.nexusFace ?? 'choam';
const old = cards.hands[choam];
const holder = game.players.find(p => cards.hands[p.id] === face);
if (holder) cards.hands[holder.id] = old;
else {
const zone = cards.deck.includes(face) ? cards.deck : cards.discard;
const index = zone.indexOf(face);
if (index < 0) throw new Error('Missing physical Nexus fixture face.');
zone.splice(index, 1);
if (old) cards.deck.push(old);
}
cards.hands[choam] = face;
Object.assign(game, { turn: 2, phase: 5, active: actor, storm: 18, ready: [], decision: null,
response: null, pendingKarama: null, phaseOpening: null, stormPending: null,
stormResolution: null, movementRemaining: [...ids] });
game = applyAction(game, actor, { type: 'card', mode: 'shipment', card: original.id, target: actor });
if (!game.pendingKull) throw new Error('Genuine Karama attempt did not create the Kull opportunity.');
return { game, choam, actor, other, event: game.pendingKull.event,
original: original.id, fuel: fuel.id, counter: counter?.id ?? null };
}
/** A native declaration is produced by actions before its Karama/Kull parent
* is suspended. The final faction roster is fixed before genuine setup. */
export function createNexusKullNativeParent(kind: 'inspection' | 'sardaukar' | 'advisors' | 'guild',
seatIds?: string[]) {
const actorFaction = { inspection: 'atreides', sardaukar: 'emperor',
advisors: 'beneGesserit', guild: 'guild' } as const;
const f = createNexusChoamKullFixture({ actorFaction: actorFaction[kind], fuel: 'weapon', counter: false, seatIds });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
game.players[1].hand.push(takeKullCard(game, f.original));
const counter = takeKullCard(game, 'karama');
game.players[2].hand.push(counter);
const face = actorFaction[kind], cards = game.nexusCards!.cards!;
const old = cards.hands[f.actor];
const holder = game.players.find(p => cards.hands[p.id] === face);
if (holder) cards.hands[holder.id] = old;
else {
const zone = cards.deck.includes(face) ? cards.deck : cards.discard;
const index = zone.indexOf(face);
if (index < 0) throw new Error(`Missing native ${face} Nexus.`);
zone.splice(index, 1);
if (old) cards.deck.push(old);
}
cards.hands[f.actor] = face;
Object.assign(game, { phase: kind === 'inspection' || kind === 'sardaukar' ? 6 : 5,
active: f.actor, karamaShipping: null, movementRemaining: null, ready: [] });
for (const p of game.players) {
p.forces = {}; p.reserves = 20; p.tanks = 0; p.spice = 20; p.shipped = false; p.moved = 0;
if (p.elites) { p.elites.forces = {}; p.elites.tanks = 0; p.elites.reserves = p.faction === 'emperor' ? 5 : 3; }
if (p.advisors) p.advisors = {};
}
if (kind === 'inspection' || kind === 'sardaukar') {
for (const p of game.players.slice(1)) { p.forces = { 'pasty_mesa:5': 7 }; p.reserves = 13; }
const choice = battles(game).find(battle => battle.territory === 'pasty_mesa');
if (!choice) throw new Error('Native parent setup has no contested battle.');
game.active = choice.chooser;
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
for (const id of [f.actor, f.other!]) if (game.battle!.preLeader && !game.battle!.preLeader.closed)
game = applyAction(game, id, { type: 'battlePreparationReady', event: game.battle!.event });
if (kind === 'inspection') {
if (game.response) game = allowNativeParent(game);
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
game = allowNativeParent(game);
game = applyAction(game, f.other!, { type: 'prescienceAnswer', value: 0 });
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
game = applyAction(game, f.actor, { type: 'nexusAtreides', event: game.battle!.event, mode: 'cunning', field: 'weapon' });
} else {
while (game.battle!.preparation)
game = applyAction(game, game.battle!.preparation.owner, { type: 'declineBattlePower' });
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
game = applyAction(game, f.actor, { type: 'nexusSardaukar', event: viewGame(game, f.actor).nexusSardaukar!.offer!.event });
}
} else if (kind === 'advisors') {
game.players[1].forces = { 'pasty_mesa:5': 2 }; game.players[1].reserves = 18;
game.players[1].advisors = { pasty_mesa: {} };
game.players[2].forces = { 'pasty_mesa:5': 1 }; game.players[2].reserves = 19;
game = applyAction(game, f.actor, { type: 'nexusAdvisors',
event: viewGame(game, f.actor).nexusAdvisors!.offer!.event, territories: ['pasty_mesa'] });
} else {
game = applyAction(game, f.actor, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 });
if (game.decision?.kind === 'guildShipment')
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
game = allowNativeParent(game);
game = applyAction(game, f.actor, { type: 'move', from: 'arrakeen:10', territory: 'hagga_basin', sector: 12, amount: 1 });
game = applyAction(game, f.actor, { type: 'endMovement', nexus: viewGame(game, f.actor).nexusGuildCunning!.offer!.event });
}
return { ...f, game, reactor: f.other!, reaction: counter.id };
}
function allowNativeParent(game: Game): Game {
for (let step = 0; game.response && step < 20; step++) {
const responder = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
if (!responder) throw new Error('Native response has no explicit responder.');
game = applyAction(game, responder.id, { type: 'passResponse' });
}
return game;
}
export function createNexusKullNestedParent(seatIds?: string[]) {
const f = createNexusChoamKullFixture({ bg: true, fuel: 'karama', seatIds });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
game = allowNativeParent(game);
game.players[1].hand.push(takeKullCard(game, f.original));
const printed = takeKullCard(game, 'ix-kull-wahad');
game.players[0].hand.push(printed);
const bgCounter = takeKullCard(game, 'Jubba Cloak');
game.players[1].hand.push(bgCounter);
Object.assign(game, { phase: 4, karamaShipping: null });
const play = viewGame(game, f.choam).choamWorthless!.plays.find(play =>
play.source === 'nexus' && play.effect === 'laLaLa' && play.card.id === f.fuel);
if (!play || !('event' in play)) throw new Error('Missing genuine Nexus parent quote.');
game = applyAction(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel,
nexus: play.event, effect: 'laLaLa', target: f.actor });
return { ...f, game, printed: printed.id, bgCounter: bgCounter.id, nexusEvent: play.event };
}
export function createNexusKullAuctionParent(seatIds?: string[]) {
const f = createNexusChoamKullFixture({ fuel: 'weapon', counter: false, seatIds });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
const payment = takeKullCard(game, f.original);
game.players[0].hand.push(payment);
const printed = takeKullCard(game, 'ix-kull-wahad');
game.players[0].hand.push(printed);
const reaction = takeKullCard(game, 'karama');
game.players[2].hand.push(reaction);
game.players[1].hand.push(takeKullCard(game, 'Shield'));
const lot = takeKullCard(game, 'ix-snooper');
Object.assign(game, { phase: 3, active: f.choam, karamaShipping: null, movementRemaining: [],
auction: { cards: [lot], index: 0, bid: 0, bidder: null, active: f.choam, passed: [], opener: 0 } });
game.players[0].spice = 1;
game = applyAction(game, f.choam, { type: 'bid', amount: 2 });
for (let step = 0; game.auction && !game.decision && step < 12; step++)
game = applyAction(game, game.auction.active!, { type: 'passBid' });
if (game.decision?.kind !== 'auctionPayment') throw new Error('CHOAM did not reach genuine winning payment.');
return { ...f, game, payment: payment.id, printed: printed.id, reactor: f.other!,
reaction: reaction.id, lot: lot.id };
}
+106
View File
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import type { ComponentProps } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame } from '../game/engine';
import type { GameView } from '../game/engine';
import { createNexusChoamKullFixture } from './fixture-nexus-choam-kull';
import { takeKullCard } from './fixture-choam-kull';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
// Static imports would resolve Next and CSS before the Node-only SSR alias hook.
const { ChoamKull } = await import('../components/choam-kull');
const { GameTable } = await import('../components/game-table');
const controls = (game: GameView, busy = false) => renderToStaticMarkup(createElement(ChoamKull, {
game, act() { assert.fail('Rendering must not play a card.'); }, busy,
}));
const table = (game: ComponentProps<typeof GameTable>['game'], busy = false) => renderToStaticMarkup(createElement(GameTable, {
game, send: async () => { assert.fail('Rendering must not submit gameplay.'); }, busy, onExit() {},
}));
void test('ordinary Treachery fuel is inspectable as itself only by the entitled CHOAM seat', () => {
for (const advanced of [false, true]) {
const fixture = createNexusChoamKullFixture({ advanced, fuel: 'weapon' });
const own = viewGame(JSON.parse(JSON.stringify(fixture.game)), fixture.choam);
const fuel = own.kullReaction!.plays.find(play => play.card.id === fixture.fuel)!.card;
const html = controls(own);
assert.ok(html.includes(fuel.name));
assert.ok(html.includes(fuel.id));
assert.ok(html.includes(`Inspect card: ${fuel.name}`));
assert.match(html, /aria-label="Inspect CHOAM Nexus card"/);
assert.match(html, /<button[^>]*>Use Kull Wahad/);
const rival = viewGame(fixture.game, fixture.actor);
for (const player of rival.players) {
if (player.id === rival.me) continue;
Object.defineProperty(player, 'hand', { get() { throw new Error('Read private rival hand'); } });
}
// The component also refuses an overfilled foreign cost projection.
rival.kullReaction = own.kullReaction;
const outsider = controls(rival);
assert.ok(!outsider.includes(fuel.id));
assert.ok(!outsider.includes(fuel.name));
assert.doesNotMatch(outsider, /<button|Inspect card/);
}
});
void test('source and fuel selection shows real printed and any-Treachery costs instead of renaming fuel Kull', () => {
const fixture = createNexusChoamKullFixture({ fuel: 'weapon' });
const printed = takeKullCard(fixture.game, 'ix-kull-wahad');
fixture.game.players.find(player => player.id === fixture.choam)!.hand.push(printed);
const own = viewGame(fixture.game, fixture.choam);
const html = controls(own);
const options = [...html.matchAll(/<option[^>]*>(.*?)<\/option>/g)].map(match => match[1]);
const fuel = own.kullReaction!.plays.find(play => play.card.id === fixture.fuel)!.card;
assert.ok(options.some(option => option.includes(fuel.name)));
assert.ok(options.some(option => option.includes(printed.name)));
assert.ok(own.kullReaction!.plays.some(play => play.source === 'printed'));
assert.ok(own.kullReaction!.plays.some(play => play.source === 'nexus' && play.card.kind !== 'worthless'));
});
void test('absent CHOAM Nexus gives decline only without exposing a false cost inspector', () => {
const fixture = createNexusChoamKullFixture({ nexusFace: 'richese', fuel: 'weapon' });
const own = viewGame(fixture.game, fixture.choam);
const html = controls(own);
assert.match(html, /<button[^>]*>Decline Kull/);
assert.doesNotMatch(html, />Use Kull Wahad|Inspect card/);
assert.ok(!html.includes(fixture.fuel));
});
void test('the actual table suspends voluntary panels throughout offer and counter, honoring all control locks', () => {
const fixture = createNexusChoamKullFixture({ fuel: 'weapon' });
const own = viewGame(fixture.game, fixture.choam);
assert.match(table(own), /<button[^>]*>Use Kull Wahad/);
const declared = applyAction(fixture.game, fixture.choam, {
type: 'kullDecision', event: fixture.event, source: 'nexus', card: fixture.fuel,
});
const counter = viewGame(JSON.parse(JSON.stringify(declared)), fixture.other!);
assert.match(table(counter), /<button[^>]*>Cancel with Karama/);
assert.ok(!table(counter).includes(fixture.fuel));
for (const [view, action] of [[own, 'Use Kull Wahad'], [counter, 'Cancel with Karama']] as const) {
assert.doesNotMatch(table(view), /<button[^>]*>[^<]*(?:Ship forces|Cash in|Play Karama|End movement|Make a bribe)/i);
assert.match(table(view, true), new RegExp(`disabled=""[^>]*>${action}`));
for (const restriction of ['paused', 'closed', 'autopilot'] as const) {
const locked: ComponentProps<typeof GameTable>['game'] = structuredClone(view);
if (restriction === 'autopilot') locked.players.find(player => player.id === locked.me)!.autopilot = 'Easy';
else locked.roomControl = { ...DEFAULT_ROOM_CONTROL, [restriction]: true };
assert.match(table(locked), new RegExp(`disabled=""[^>]*>${action}`));
}
}
});
aliases.deregister();
+469
View File
@@ -0,0 +1,469 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusKullGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { createNexusChoamKullFixture, createNexusKullNativeParent, createNexusKullNestedParent,
createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
import { spiceDeck } from '../game/cards';
import { createNexusCardPhase } from '../game/nexus-card-phase';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const held = (game: Game, owner: string, card: string) => game.players.find(p => p.id === owner)!.hand.some(c => c.id === card);
function inventory(game: Game) {
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(card => card.id).sort();
}
function finish(game: Game): Game {
for (let step = 0; game.pendingKull && step < 20; step++) {
const responder = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
game = responder ? applyAction(reload(game), responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(reload(game));
}
assert.equal(game.pendingKull, null);
return game;
}
function reject(game: Game, owner: string, action: Action) {
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, owner, action));
assert.equal(JSON.stringify(game), before);
}
void test('fresh admission accepts exactly native classic CHOAM, physical CHOAM/Ix and only Nexus', () => {
const lobby = () => {
const game = createGame('NXKULL01', newPlayer('c', 'CHOAM', 'choam'), false, ['choam', 'ix']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const p of game.players) p.ready = true;
game.nexusCards = { cards: null, phase: null };
return game;
};
const valid = lobby(), before = JSON.stringify(valid);
const initialized = initializeNexusKullGameForAudit(valid);
assert.equal(JSON.stringify(valid), before);
assert.equal(initialized.kullPreview, true);
assert.equal(initialized.nexusKullPreview, true);
assert.ok(initialized.deck.some(card => card.id === 'ix-kull-wahad'));
assert.deepEqual(initialized.spiceDeck.map(card => JSON.stringify(card)).sort(),
spiceDeck().map(card => JSON.stringify(card)).sort());
assert.equal(initialized.spiceDeck.some(card => 'sandtrout' in card), false);
for (const corrupt of [
(g: Game) => { g.expansions = ['choam']; },
(g: Game) => { g.nexusCards = undefined; },
(g: Game) => { g.players[1] = newPlayer('e', 'Richese', 'richese'); g.players[1].ready = true; },
(g: Game) => { g.semutaPreview = true; },
(g: Game) => { g.kullSequence = 1; },
(g: Game) => { g.turn = 2; },
(g: Game) => { g.response = { kind: 'eliteStrength', owner: 'e', passed: [] }; },
(g: Game) => { g.sandtrout = true; },
(g: Game) => { g.discoveryEnabled = true; },
]) {
const game = lobby(); corrupt(game);
const saved = JSON.stringify(game);
assert.throws(() => initializeNexusKullGameForAudit(game));
assert.equal(JSON.stringify(game), saved);
}
});
for (const advanced of [false, true]) for (const fuel of ['karama', 'worthless', 'weapon'] as const)
void test(`Nexus Kull succeeds once with ${fuel} fuel in ${advanced ? 'Advanced' : 'Basic'}`, () => {
const f = createNexusChoamKullFixture({ advanced, fuel });
const physical = inventory(f.game);
const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
assert.equal(held(declared, f.choam, f.fuel), f.counter !== null);
assert.ok(held(declared, f.actor, f.original));
assert.equal(declared.nexusCards!.cards!.hands[f.choam], null);
assert.equal(declared.nexusChoamHistory!.at(-1)!.stage, f.counter ? 'pending' : 'complete');
const done = finish(declared);
assert.deepEqual(inventory(done), physical);
assert.ok(held(done, f.actor, f.original));
assert.equal(held(done, f.choam, f.fuel), false);
assert.equal(done.discard.filter(card => card.id === f.fuel).length, 1);
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1);
assert.equal(done.nexusChoamHistory!.at(-1)!.stage, 'complete');
assert.equal(done.karamaShipping, null);
reject(done, f.actor, { type: 'card', mode: 'shipment', card: f.original, target: f.actor });
reject(done, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
});
void test('distinct prevention retains fuel, spends accepted Nexus, and resumes original exactly once', () => {
const f = createNexusChoamKullFixture({ fuel: 'weapon' });
const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
reject(declared, f.actor, { type: 'card', mode: 'cancel', card: f.original });
const done = applyAction(reload(declared), f.other!, { type: 'card', mode: 'cancel', card: f.counter! });
assert.equal(done.pendingKull, null);
assert.ok(held(done, f.choam, f.fuel));
assert.equal(done.discard.filter(card => card.id === f.original).length, 1);
assert.equal(done.discard.filter(card => card.id === f.counter).length, 1);
assert.deepEqual(done.karamaShipping, { player: f.actor, owner: f.actor, card: f.original });
assert.equal(done.nexusChoamHistory!.at(-1)!.stage, 'canceled');
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1);
assert.equal(viewGame(done, f.actor).karamaBlocked, null);
reject(done, f.other!, { type: 'card', mode: 'cancel', card: f.counter! });
});
void test('BG interception precedes conversion; successful Kull keeps original while decline converts once', () => {
const f = createNexusChoamKullFixture({ bg: true, fuel: 'worthless' });
assert.equal(f.game.pendingKarama, null);
assert.ok(held(f.game, f.actor, f.original));
const success = finish(applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel }));
assert.ok(held(success, f.actor, f.original));
assert.equal(success.pendingKarama, null);
const decline = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
assert.equal(decline.response?.kind, 'worthlessKarama');
assert.equal(decline.discard.filter(card => card.id === f.original).length, 1);
assert.equal(decline.nexusCards!.cards!.hands[f.choam], 'choam');
assert.equal(decline.nexusChoamHistory, undefined);
});
void test('source-required actions reject foreign, stale, malformed and source aliases immutably', () => {
const f = createNexusChoamKullFixture();
for (const action of [
{ type: 'kullDecision', event: f.event, card: f.fuel },
{ type: 'kullDecision', event: f.event, source: 'printed', card: f.fuel },
{ type: 'kullDecision', event: f.event, source: 'nexus', card: f.original },
{ type: 'kullDecision', event: 'stale', source: 'nexus', card: f.fuel },
{ type: 'kullDecision', event: f.event, decline: true, source: 'nexus' },
{ type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel, effect: 'kull' },
]) reject(f.game, f.choam, action);
reject(f.game, f.actor, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
});
void test('offer timing and other-player views are independent of hidden Nexus face and costs', () => {
const f = createNexusChoamKullFixture();
const other = reload(f.game);
const cards = other.nexusCards!.cards!;
const richeseHolder = other.players.find(p => cards.hands[p.id] === 'richese');
if (richeseHolder) cards.hands[richeseHolder.id] = 'choam';
else { const index = cards.deck.indexOf('richese'); cards.deck[index] = 'choam'; }
cards.hands[f.choam] = 'richese';
for (const viewer of [f.actor, f.other!]) {
assert.deepEqual(viewGame(f.game, viewer), viewGame(other, viewer));
assert.deepEqual(viewGame(f.game, viewer).kullReaction!.plays, []);
}
// Change hidden fuel availability without changing the public draw-pile size.
// Only the third seat compares this transfer: the recipient knows its own hand.
other.players.find(p => p.id === f.actor)!.hand.push(...other.players.find(p => p.id === f.choam)!.hand.splice(0));
assert.deepEqual(inventory(other), inventory(f.game));
assert.deepEqual(viewGame(f.game, f.other!), viewGame(other, f.other!));
assert.deepEqual(viewGame(other, f.choam).kullReaction!.plays, []);
assert.deepEqual(viewGame(f.game, f.choam).kullReaction!.plays.map(play => [play.source, play.card.id]), [['nexus', f.fuel]]);
});
void test('printed choice in new profile does not spend Nexus and cannot alias another printed cost', () => {
const f = createNexusChoamKullFixture();
const original = reload(f.game);
original.players[0].hand.push(takeKullCard(original, 'ix-kull-wahad'));
const declared = applyAction(original, f.choam, { type: 'kullDecision', event: f.event, source: 'printed', card: 'ix-kull-wahad' });
const done = finish(declared);
assert.equal(done.nexusCards!.cards!.hands[f.choam], 'choam');
assert.equal(done.nexusChoamHistory, undefined);
assert.ok(held(done, f.choam, f.fuel));
assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
});
void test('deleted source, receipt, history, cost and original frame reject before projection or automatic settlement', () => {
const f = createNexusChoamKullFixture();
const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
for (const corrupt of [
(g: Game) => { delete g.pendingKull!.selection; },
(g: Game) => { delete g.pendingKull; },
(g: Game) => { delete g.nexusChoamHistory; },
(g: Game) => { delete g.pendingChoamWorthless!.nexusEvent; },
(g: Game) => { g.players[0].hand = []; },
(g: Game) => { g.players[1].hand = []; },
(g: Game) => { g.pendingKull!.resume.response = { kind: 'eliteStrength', owner: f.actor, passed: [] }; },
]) {
const game = reload(declared); corrupt(game);
const before = JSON.stringify(game);
assert.throws(() => viewGame(game, f.other!));
assert.throws(() => normalizeAutomaticGame(game));
reject(game, f.other!, { type: 'passResponse' });
assert.equal(JSON.stringify(game), before);
}
});
void test('Nexus Kull reserves a suspended printed parent cost and restores that exact response after success', () => {
const f = createNexusChoamKullFixture();
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
const original = takeKullCard(game, f.original);
game.players[1].hand.push(original);
const fuel = takeKullCard(game, 'ix-hunter-seeker');
game.players[0].hand.push(fuel);
Object.assign(game, { phase: 4, karamaShipping: null });
game = applyAction(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, target: f.actor });
const response = reload(game).response;
const offered = applyAction(game, f.actor, { type: 'card', mode: 'cancel', card: original.id });
const event = offered.pendingKull!.event;
assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false);
reject(offered, f.choam, { type: 'kullDecision', event, source: 'nexus', card: f.fuel });
const done = finish(applyAction(offered, f.choam, { type: 'kullDecision', event, source: 'nexus', card: fuel.id }));
assert.deepEqual(done.response, response);
assert.equal(done.pendingChoamWorthless!.card, f.fuel);
assert.ok(held(done, f.choam, f.fuel));
assert.ok(held(done, f.actor, original.id));
assert.equal(done.nexusChoamHistory!.at(-1)!.stage, 'complete');
});
void test('printed Kull discovers a suspended Nexus parent through direct and BG counter overlays', () => {
const f = createNexusChoamKullFixture({ bg: true });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
for (let step = 0; game.response?.kind === 'worthlessKarama' && step < 20; step++) {
const p = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game);
}
game.players[1].hand.push(takeKullCard(game, f.original));
game.players[0].hand.push(takeKullCard(game, 'ix-kull-wahad'));
const bgCounter = takeKullCard(game, 'Jubba Cloak');
game.players[1].hand.push(bgCounter);
Object.assign(game, { phase: 4, karamaShipping: null });
const play = viewGame(game, f.choam).choamWorthless!.plays.find(play =>
play.source === 'nexus' && play.effect === 'laLaLa' && play.card.id === f.fuel);
assert.ok(play && 'event' in play);
const nexusEvent = play.event;
const parent = applyAction(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, nexus: nexusEvent, effect: 'laLaLa', target: f.actor });
const offered = applyAction(parent, f.actor, { type: 'card', mode: 'cancel', card: f.original });
const declaration = applyAction(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad' });
assert.equal(declaration.nexusChoamHistory!.at(-1)!.stage, 'pending');
assert.equal(viewGame(declaration, f.actor).response?.kind, 'choamWorthless');
const converted = applyAction(declaration, f.actor, { type: 'card', mode: 'cancel', card: bgCounter.id });
assert.equal(converted.response?.kind, 'worthlessKarama');
assert.equal(viewGame(reload(converted), f.other!).response?.kind, 'worthlessKarama');
assert.equal(normalizeAutomaticGame(reload(converted)).pendingKull?.stage, 'counter');
const success = finish(declaration);
assert.equal(success.pendingChoamWorthless?.nexusEvent, nexusEvent);
assert.equal(success.nexusChoamHistory!.at(-1)!.stage, 'pending');
const orphan = reload(declaration);
delete orphan.nexusChoamHistory;
assert.throws(() => viewGame(orphan, f.other!));
assert.throws(() => normalizeAutomaticGame(orphan));
});
void test('Nexus fuel needed by a live own Truthtrance battle promise cannot be accepted', () => {
const f = createNexusChoamKullFixture({ fuel: 'weapon' });
let game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
Object.assign(game, { phase: 6, active: f.choam, karamaShipping: null });
for (const p of game.players) { p.forces = {}; p.reserves = 20; }
for (const p of game.players.slice(0, 2)) { p.forces = { 'arrakeen:10': 3 }; p.reserves = 17; }
game = applyAction(game, f.choam, { type: 'chooseBattle', territory: 'arrakeen', target: f.actor });
for (let step = 0; game.response && step < 20; step++) {
const p = game.players.find(p => {
const controls = viewGame(game, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
game = p ? applyAction(game, p.id, { type: 'passResponse' }) : normalizeAutomaticGame(game);
}
game.battle!.preparation = undefined;
game.battle!.truthPromises = [{ player: f.choam, asker: f.other!, claim: { kind: 'weapon', name: 'Hunter Seeker' }, answer: true }];
game.response = { kind: 'eliteStrength', owner: f.actor, passed: [] };
const offered = applyAction(game, f.other!, { type: 'card', mode: 'cancel', card: f.counter! });
assert.equal(viewGame(offered, f.choam).kullReaction!.plays.some(play => play.card.id === f.fuel), false);
reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event, source: 'nexus', card: f.fuel });
assert.equal(offered.nexusCards!.cards!.hands[f.choam], 'choam');
assert.ok(held(offered, f.choam, f.fuel));
});
void test('generic any-time CHOAM Cunning cannot bypass the canonical reactive opportunity', () => {
const f = createNexusChoamKullFixture();
const game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
const play = viewGame(game, f.choam).choamWorthless!.plays.find(play =>
play.source === 'nexus' && play.effect === 'kull' && play.card.id === f.fuel);
assert.ok(play && 'event' in play && play.blocked);
reject(game, f.choam, { type: 'card', mode: 'choam', card: f.fuel, nexus: play.event, effect: 'kull' });
assert.equal(game.nexusCards!.cards!.hands[f.choam], 'choam');
});
void test('verified pre-source printed counters migrate without mutating saves or loosening new actions', () => {
const base = choamKullGame();
const offer = applyAction(base, 'e', kullShipmentAttempt(base));
const declaration = applyAction(offer, 'c', { type: 'kullDecision',
event: offer.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad' });
const old = reload(declaration);
delete old.pendingKull!.selection;
const signature = JSON.parse(old.pendingKull!.signature) as Record<string, unknown>;
delete signature.selection;
old.pendingKull!.signature = JSON.stringify(signature);
const saved = JSON.stringify(old);
assert.equal(viewGame(old, 'h').response?.kind, 'choamWorthless');
const normalized = normalizeAutomaticGame(old);
assert.deepEqual(normalized.pendingKull!.selection, { source: 'printed', card: 'ix-kull-wahad' });
assert.equal(JSON.stringify(old), saved);
const resumed = applyAction(old, 'h', { type: 'card', mode: 'cancel', card: base.players[3].hand[0].id });
assert.equal(resumed.pendingKull, null);
assert.deepEqual(resumed.karamaShipping, { player: 'e', owner: 'e', card: base.players[1].hand[0].id });
assert.ok(held(resumed, 'c', 'ix-kull-wahad'));
const success = finish(normalized);
assert.ok(held(success, 'e', base.players[1].hand[0].id));
assert.equal(held(success, 'c', 'ix-kull-wahad'), false);
const corrupt = reload(old);
corrupt.pendingKull!.resume.response = { kind: 'eliteStrength', owner: 'e', passed: [] };
const before = JSON.stringify(corrupt);
assert.throws(() => viewGame(corrupt, 'c'));
assert.throws(() => normalizeAutomaticGame(corrupt));
reject(corrupt, 'h', { type: 'passResponse' });
assert.equal(JSON.stringify(corrupt), before);
reject(offer, 'c', { type: 'kullDecision', event: offer.pendingKull!.event, card: 'ix-kull-wahad' });
});
void test('independent canonical descriptors and unique live custody govern fuel before and after JSON restore', () => {
const f = createNexusChoamKullFixture({ fuel: 'weapon' });
for (const change of [
(g: Game) => { g.players[0].hand[0].id = 'invented-treachery'; },
(g: Game) => { g.players[0].hand[0].name = 'Invented Weapon'; },
(g: Game) => { g.players[0].hand[0].kind = 'worthless'; },
(g: Game) => { g.players[0].hand[0].effect = 'karama'; },
(g: Game) => { g.deck.push({ ...g.players[0].hand[0] }); },
]) {
const offer = reload(f.game); change(offer);
const card = offer.players[0].hand[0].id;
assert.equal(viewGame(offer, f.choam).kullReaction!.plays.some(play => play.card.id === card), false);
reject(offer, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card });
const declared = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, source: 'nexus', card: f.fuel });
const corrupt = reload(declared); change(corrupt);
const before = JSON.stringify(corrupt);
assert.throws(() => viewGame(corrupt, f.other!));
assert.throws(() => normalizeAutomaticGame(corrupt));
reject(corrupt, f.other!, { type: 'passResponse' });
assert.equal(JSON.stringify(corrupt), before);
}
});
for (const kind of ['inspection', 'sardaukar', 'advisors', 'guild'] as const)
void test(`native ${kind} parent remains readable and resolves its saved response through Nexus Kull`, () => {
const f = createNexusKullNativeParent(kind);
const physical = inventory(f.game), parent = reload(f.game).response;
const before = JSON.stringify(f.game);
const offered = applyAction(f.game, f.reactor, { type: 'card', mode: 'cancel', card: f.reaction });
assert.equal(JSON.stringify(f.game), before);
assert.deepEqual(offered.pendingKull!.resume.response, parent);
const declared = applyAction(offered, f.choam, { type: 'kullDecision',
event: offered.pendingKull!.event, source: 'nexus', card: f.fuel });
assert.equal(viewGame(reload(declared), f.reactor).response?.kind, 'choamWorthless');
assert.equal(normalizeAutomaticGame(reload(declared)).pendingKull?.stage, 'counter');
const result = finish(reload(declared));
assert.deepEqual(inventory(result), physical);
assert.ok(held(result, f.reactor, f.reaction));
assert.equal(result.nexusChoamHistory!.at(-1)!.stage, 'complete');
if (kind === 'inspection') {
assert.equal(result.battle!.nexusInspection!.stage, 'answer');
assert.equal(result.battle!.preparation!.kind, 'nexusPrescienceAnswer');
const answered = applyAction(reload(result), f.reactor, { type: 'nexusPrescienceAnswer',
event: result.battle!.event, value: null });
assert.equal(answered.battle!.nexusInspection!.stage, 'answered');
} else if (kind === 'sardaukar') assert.equal(result.nexusSardaukarHistory!.at(-1)!.stage, 'active');
else if (kind === 'advisors') {
assert.equal(result.nexusAdvisorHistory!.at(-1)!.stage, 'completed');
assert.equal(Object.hasOwn(result.players[1].advisors ?? {}, 'pasty_mesa'), false);
} else assert.equal(result.nexusGuildCunningHistory!.at(-1)!.stage, 'secondShipment');
const canceled = applyAction(reload(declared), f.actor, { type: 'card', mode: 'cancel', card: f.original });
assert.equal(canceled.pendingKull, null);
assert.equal(canceled.nexusChoamHistory!.at(-1)!.stage, 'canceled');
assert.equal(canceled.discard.filter(card => card.id === f.reaction).length, 1);
if (kind === 'inspection') assert.equal(canceled.battle!.nexusInspection!.stage, 'canceled');
else if (kind === 'sardaukar') assert.equal(canceled.nexusSardaukarHistory!.at(-1)!.stage, 'canceled');
else if (kind === 'advisors') assert.equal(canceled.nexusAdvisorHistory!.at(-1)!.stage, 'canceled');
else assert.equal(canceled.nexusGuildCunningHistory!.at(-1)!.stage, 'canceled');
viewGame(reload(canceled), f.choam);
});
void test('a Karama-funded Nexus parent stays reserved in authoritative and projected BG counter overlays', () => {
const f = createNexusKullNestedParent();
const offered = applyAction(f.game, f.actor, { type: 'card', mode: 'cancel', card: f.original });
const declared = applyAction(offered, f.choam, { type: 'kullDecision',
event: offered.pendingKull!.event, source: 'printed', card: f.printed });
const converted = applyAction(reload(declared), f.actor, { type: 'card', mode: 'cancel', card: f.bgCounter });
assert.equal(converted.response?.kind, 'worthlessKarama');
assert.equal(viewGame(reload(converted), f.choam).responseControls!.cancelCards.includes(f.fuel), false);
reject(converted, f.choam, { type: 'card', mode: 'cancel', card: f.fuel });
const restored = normalizeAutomaticGame(reload(converted));
assert.ok(held(restored, f.choam, f.fuel));
assert.equal(restored.pendingKull!.worthless!.card, f.fuel);
const success = finish(reload(declared));
assert.equal(success.pendingChoamWorthless?.nexusEvent, f.nexusEvent);
assert.deepEqual(success.response, f.game.response);
});
for (const source of ['printed', 'nexus'] as const)
void test(`${source} Kull keeps CHOAM's own winning payment covered while admitting other legitimate fuel`, () => {
const f = createNexusKullAuctionParent();
const before = JSON.stringify(f.game);
const offered = applyAction(f.game, f.reactor, { type: 'card', mode: 'special',
card: f.reaction, target: f.actor, amount: 1 });
assert.equal(JSON.stringify(f.game), before);
assert.equal(offered.pendingKull!.resume.decision?.kind, 'auctionPayment');
const plays = viewGame(reload(offered), f.choam).kullReaction!.plays;
assert.equal(plays.some(play => play.card.id === f.payment), false);
assert.ok(plays.some(play => play.source === 'printed' && play.card.id === f.printed));
assert.ok(plays.some(play => play.source === 'nexus' && play.card.id === f.fuel));
reject(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event,
source: 'nexus', card: f.payment });
const declared = applyAction(offered, f.choam, { type: 'kullDecision', event: offered.pendingKull!.event,
source, card: source === 'printed' ? f.printed : f.fuel });
const success = finish(reload(declared));
assert.equal(success.decision?.kind, 'auctionPayment');
assert.ok(held(success, f.choam, f.payment));
const paid = applyAction(reload(success), f.choam, { type: 'decision', karama: true, card: f.payment });
assert.ok(held(paid, f.choam, f.lot));
assert.equal(paid.discard.filter(card => card.id === f.payment).length, 1);
assert.equal(paid.players[0].spice, 1);
});
void test('the Nexus-only Kull profile keeps unsupported Fremen remote Cunning behind its existing pre-offer guard', () => {
const f = createNexusChoamKullFixture({ actorFaction: 'fremen', fuel: 'weapon', counter: false });
const game = applyAction(f.game, f.choam, { type: 'kullDecision', event: f.event, decline: true });
game.players[1].hand.push(takeKullCard(game, f.original));
const counter = takeKullCard(game, 'karama');
game.players[2].hand.push(counter);
const cards = game.nexusCards!.cards!, old = cards.hands[f.actor];
const holder = game.players.find(p => cards.hands[p.id] === 'fremen');
if (holder) cards.hands[holder.id] = old;
else {
const zone = cards.deck.includes('fremen') ? cards.deck : cards.discard;
const index = zone.indexOf('fremen');
assert.ok(index >= 0);
zone.splice(index, 1);
if (old) cards.deck.push(old);
}
cards.hands[f.actor] = 'fremen';
for (const p of game.players) {
p.forces = {}; p.reserves = 20;
if (p.elites) { p.elites.forces = {}; p.elites.reserves = 3; }
}
game.players[1].forces = { 'wind_pass:14': 2 }; game.players[1].reserves = 18;
Object.assign(game, { phase: 1, karamaShipping: null });
game.nexusCards!.phase = createNexusCardPhase(game.turn);
const before = JSON.stringify(game);
const offered = applyAction(game, f.actor, { type: 'card', mode: 'special',
card: f.original, territory: 'hagga_basin' });
assert.equal(JSON.stringify(game), before);
const summoned = applyAction(reload(offered), f.choam, {
type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
assert.equal(summoned.players[1].specialKaramaUsed, true);
assert.equal(summoned.nexusFremenCunningOffer ?? null, null);
assert.equal(summoned.nexusFremenCunningRides ?? null, null);
assert.equal(summoned.nexusCards!.cards!.hands[f.actor], 'fremen');
assert.equal(summoned.discard.filter(card => card.id === f.original).length, 1);
reject(summoned, f.actor, { type: 'decision', event: 'unsupported-remote-ride', accept: true });
viewGame(reload(summoned), f.actor);
});
void test('a canceled CHOAM-effect block does not remove ordinary Karama auction-payment coverage', () => {
const f = createNexusKullAuctionParent();
f.game.choamWorthlessBlocked = { turn: f.game.turn, phase: f.game.phase, cards: [f.payment] };
const offered = applyAction(f.game, f.reactor, { type: 'card', mode: 'special',
card: f.reaction, target: f.actor, amount: 1 });
const plays = viewGame(reload(offered), f.choam).kullReaction!.plays;
assert.ok(plays.some(play => play.source === 'printed' && play.card.id === f.printed));
assert.equal(plays.some(play => play.card.id === f.payment), false);
const declared = applyAction(offered, f.choam, { type: 'kullDecision',
event: offered.pendingKull!.event, source: 'printed', card: f.printed });
const done = finish(reload(declared));
assert.ok(held(done, f.choam, f.payment));
const paid = applyAction(reload(done), f.choam, { type: 'decision', karama: true, card: f.payment });
assert.ok(held(paid, f.choam, f.lot));
assert.equal(paid.discard.filter(card => card.id === f.payment).length, 1);
assert.equal(paid.players[0].spice, 1);
});
+873
View File
@@ -0,0 +1,873 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync, SQLOutputValue } from 'node:sqlite';
import { viewGame, type Action, type Game, type GameView } from '../game/engine';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { createNexusChoamKullFixture, createNexusKullNativeParent,
createNexusKullNestedParent, createNexusKullAuctionParent } from './fixture-nexus-choam-kull';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
const plain = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
/** Malformed or forged client payloads used only to prove rejection; never valid actions. */
const forged = (payload: object): Action => payload as Action;
const player = (g: Game, id: string) => {
const found = g.players.find(p => p.id === id);
assert.ok(found, `missing seated identity ${id}`);
return found;
};
/** Offer-stage contract of the shared Nexus Kull fixture, named for authenticated SQL setup. */
type NexusChoamKullOptions = Parameters<typeof createNexusChoamKullFixture>[0];
type NexusChoamKullGame = ReturnType<typeof createNexusChoamKullFixture>;
type CredentialRows = {
seats: Record<string, SQLOutputValue>[];
entries: Record<string, SQLOutputValue>[];
keys: Record<string, SQLOutputValue>[];
recoveries: Record<string, SQLOutputValue>[];
};
function physical(g: Game) {
const ids = [
...g.deck, ...g.discard, ...(g.richeseCache ?? []),
...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []),
].map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'physical Treachery custody stays unique');
return ids;
}
function nexusInventory(g: Game) {
const cards = g.nexusCards?.cards;
assert.ok(cards, 'the Nexus Kull table owns its physical Nexus inventory');
validateNexusCards(cards, g.players);
assert.deepEqual([
...cards.deck, ...cards.discard,
...Object.values(cards.hands).filter(card => card !== null),
].sort(), [...NEXUS_FACTIONS].sort());
}
function credentials(sqlite: DatabaseSync): CredentialRows {
return {
seats: sqlite.prepare('SELECT * FROM seats ORDER BY room_code,player_id,token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id,operation_hash').all(),
keys: sqlite.prepare('SELECT * FROM seat_recovery_keys ORDER BY room_code,player_id').all(),
recoveries: sqlite.prepare('SELECT * FROM seat_recovery_receipts ORDER BY room_code,player_id,operation_hash').all(),
};
}
async function persisted(t: test.TestContext, options: NexusChoamKullOptions = {},
create?: (seatIds: string[]) => NexusChoamKullGame): Promise<Fixture> {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Nexus Kull SQL', 'choam', options.advanced ?? true, ['choam', 'ix']);
const code = made.view.code;
const tokens = [made.token];
tokens.push((await store.rooms.joinRoom(code, 'Actor', options.actorFaction ?? (options.bg ? 'beneGesserit' : 'emperor'))).token!);
tokens.push((await store.rooms.joinRoom(code, 'Other', 'harkonnen')).token!);
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
const ids = auths.map(auth => auth.playerId);
const madeGame: NexusChoamKullGame = create ? create(ids) : createNexusChoamKullFixture({ ...options, seatIds: ids });
const initial = madeGame.game;
initial.code = code;
initial.host = auths[0].playerId;
initial.version = (await store.rooms.readRoom(code)).version;
const save = (g: Game) => assert.equal(
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(g), g.version, code).changes, 1);
save(initial);
const unrelated = await store.rooms.createRoom('Unrelated preserved room', 'atreides', false, []);
const unrelatedRow = store.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(unrelated.view.code);
const credentialRows = credentials(store.sqlite);
const stock = physical(initial);
nexusInventory(initial);
store.writes.length = 0;
return {
...store, ...madeGame, initial, code, tokens, auths, save, stock, credentialRows,
unrelatedCode: unrelated.view.code, unrelatedToken: unrelated.token, unrelatedRow,
};
}
interface Fixture extends NexusChoamKullGame {
rooms: typeof Rooms;
restart: () => typeof Rooms;
sqlite: DatabaseSync;
hooks: { beforeWrite?: () => Promise<void>; beforeStatement?: (sql: string) => Promise<void> };
writes: { expected: number; changes: number }[];
initial: Game;
code: string;
tokens: string[];
auths: SeatAuth[];
save(g: Game): void;
stock: string[];
credentialRows: CredentialRows;
unrelatedCode: string;
unrelatedToken: string;
unrelatedRow: Record<string, SQLOutputValue> | undefined;
}
const row = (f: Fixture) => f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.code);
function protectedRows(f: Fixture) {
assert.deepEqual(credentials(f.sqlite), f.credentialRows, 'consumer credential rows are never rewritten');
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code = ?').get(f.unrelatedCode),
f.unrelatedRow, 'an unrelated room is never touched');
}
function inventory(f: Fixture, g: Game) {
assert.deepEqual(physical(g), f.stock, 'the full physical Treachery inventory is conserved');
nexusInventory(g);
protectedRows(f);
}
async function authenticate(f: Fixture, id: string) {
const index = f.auths.findIndex(auth => auth.playerId === id);
assert.ok(index >= 0, `no credential for ${id}`);
const auth = await f.restart().authenticate(f.code, f.tokens[index]);
// Authorization is the credential's values, not its realm, prototype or brand.
assert.equal(auth.playerId, id);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
return auth;
}
async function act(f: Fixture, g: Game, id: string, action: Action) {
const rooms = f.restart();
await rooms.act(f.code, await authenticate(f, id), g.version, action, clock);
return f.restart().readRoom(f.code);
}
async function restored(f: Fixture, g: Game) {
const before = row(f);
const writes = f.writes.length;
const views: GameView[] = [];
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
assert.equal(auth.playerId, f.auths[index].playerId);
assert.equal(auth.tokenHash, f.auths[index].tokenHash);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(plain(view), plain(viewGame(g, auth.playerId)));
assert.equal(view.nexusKullPreview, true);
for (const key of ['pendingKull', 'pendingChoamWorthless', 'nexusChoamHistory', 'nexusChoamLast'])
assert.equal(Object.hasOwn(view, key), false, 'intent, receipt and cursor stay private');
for (const rival of view.players.filter(p => p.id !== auth.playerId)) {
assert.equal(Object.hasOwn(rival, 'hand'), false);
assert.equal(Object.hasOwn(rival, 'spice'), false);
}
views.push(view);
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before, 'refresh or recovery cannot acknowledge a human opportunity');
assert.equal(f.writes.length, writes);
inventory(f, g);
return views;
}
async function stationary(f: Fixture, g: Game) {
const before = row(f);
const writes = f.writes.length;
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, writes);
await restored(f, g);
}
async function rejected(f: Fixture, g: Game, id: string, action: Action, version = g.version) {
const before = row(f);
const attempted: string[] = [];
f.hooks.beforeStatement = async sql => { attempted.push(sql); };
try {
await assert.rejects(f.restart().act(f.code, await authenticate(f, id), version, action, clock));
} finally { delete f.hooks.beforeStatement; }
assert.deepEqual(attempted, [], 'a rejected Kull choice must not attempt a room write');
assert.deepEqual(row(f), before);
protectedRows(f);
}
async function settle(f: Fixture, g: Game) {
for (let step = 0; g.response && step < 16; step++) {
const response = g.response;
const responder = g.players.find(p => !response.passed.includes(p.id));
assert.ok(responder, 'an open response retains an unpassed real seat');
g = await act(f, g, responder.id, { type: 'passResponse' });
await restored(f, g);
}
assert.equal(g.response, null);
return g;
}
async function race(f: Fixture, g: Game, id: string, first: Action, second: Action) {
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 auth = await authenticate(f, id);
let results: PromiseSettledResult<unknown>[];
try {
// Both competitors park at the version fence, then commit independently.
results = await Promise.allSettled([
f.rooms.act(f.code, auth, g.version, first, clock).finally(release),
f.restart().act(f.code, auth, g.version, second, clock).finally(release),
]);
} finally { delete f.hooks.beforeWrite; }
assert.equal(arrivals, 2, 'both competitors reached the version fence');
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
assert.deepEqual(f.writes.map(write => write.changes).sort((a, b) => a - b), [0, 1]);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, g.version + 1, 'exactly one competitor commits');
await restored(f, done);
return done;
}
for (const advanced of [false, true])
for (const fuel of ['karama', 'weapon', 'worthless'] as const)
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} Nexus Kull spends its exact held ${fuel} fuel and the physical CHOAM Nexus once, then bans the interrupted Karama`, async t => {
const f = await persisted(t, { advanced, fuel });
const { choam, actor, event, original, fuel: fuelCard, counter } = f;
assert.equal(f.initial.pendingKull?.stage, 'offer');
assert.equal(f.initial.pendingKull?.event, event);
assert.equal(player(f.initial, actor).hand.some(card => card.id === original), true);
assert.equal(f.initial.nexusCards!.cards!.hands[choam], 'choam');
assert.equal(player(f.initial, choam).hand.some(card => card.id === fuelCard), true);
// Canonical base plus Ix holds two Karama: one original and one counter at most.
const heldCounter = counter !== null;
if (heldCounter) assert.notEqual(counter, original);
const views = await restored(f, f.initial);
const reaction = views[0].kullReaction;
assert.ok(reaction);
assert.deepEqual({ event: reaction.event, player: reaction.player, target: reaction.target, intent: reaction.intent },
{ event, player: choam, target: actor, intent: 'shipment' });
assert.deepEqual(reaction.plays.map(play => ({ source: play.source, effect: play.effect, card: play.card.id, event: play.event, blocked: play.blocked })),
[{ source: 'nexus', effect: 'kull', card: fuelCard, event, blocked: null }]);
assert.equal(reaction.canDecline, true);
for (const view of views.slice(1))
assert.deepEqual(view.kullReaction,
{ event, player: choam, target: actor, intent: 'shipment', plays: [], canDecline: false, blocked: null });
await stationary(f, f.initial);
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuelCard });
assert.equal(declared.version, f.initial.version + 1);
const nexusEvent = JSON.stringify(['nexusChoam', declared.turn, declared.phase, choam, fuelCard, 'kull']);
// Without a distinguishable held Karama nobody can prevent it, so the
// real window resolves inside the accepted declaration instead.
assert.equal(declared.response === null, !heldCounter);
if (heldCounter) {
assert.equal(declared.pendingKull?.stage, 'counter');
assert.deepEqual(declared.pendingKull?.selection, { source: 'nexus', card: fuelCard, nexusEvent });
assert.deepEqual(declared.pendingChoamWorthless, {
owner: choam, card: fuelCard, effect: 'kull', target: actor, revival: false, nexusEvent,
});
assert.equal(declared.response?.kind, 'choamWorthless');
assert.equal(declared.response?.intent, 'Kull Wahad');
assert.equal(declared.response?.owner, choam);
assert.equal(declared.response?.recipient, actor);
assert.equal(player(declared, choam).hand.some(card => card.id === fuelCard), true,
'the declared fuel is only retained while the response is pending');
assert.equal(player(declared, actor).hand.some(card => card.id === original), true,
'the interrupted card stays reserved rather than spent');
assert.equal(declared.discard.some(card => card.id === original), false);
assert.equal(declared.nexusChoamHistory?.length, 1);
assert.equal(declared.nexusChoamHistory![0].stage, 'pending');
assert.deepEqual(declared.nexusChoamLast, { event: nexusEvent, stage: 'pending' });
await restored(f, declared);
await stationary(f, declared);
}
assert.equal(declared.nexusCards!.cards!.hands[choam], null);
assert.deepEqual(declared.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam'],
'the declared Nexus is spent on acceptance');
assert.equal(declared.nexusChoamHistory![0].receipt.card, fuelCard);
assert.equal(declared.nexusChoamHistory![0].receipt.effect, 'kull');
assert.equal(declared.nexusChoamHistory![0].receipt.owner, choam);
const done = heldCounter ? await settle(f, declared) : declared;
assert.equal(done.pendingKull ?? null, null);
assert.equal(done.pendingChoamWorthless ?? null, null);
assert.equal(done.response, null);
assert.equal(done.discard.filter(card => card.id === fuelCard).length, 1, 'the chosen fuel is discarded exactly once');
assert.equal(player(done, choam).hand.some(card => card.id === fuelCard), false);
assert.equal(done.discard.some(card => card.id === original), false, 'the interrupted card is not discarded');
assert.equal(player(done, actor).hand.filter(card => card.id === original).length, 1);
assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam'],
'the Nexus is not returned or double spent');
assert.equal(done.nexusChoamHistory?.length, 1);
assert.equal(done.nexusChoamHistory![0].stage, 'complete');
assert.deepEqual(done.nexusChoamLast, { event: nexusEvent, stage: 'complete' });
assert.deepEqual((done.kullRestrictions ?? []).filter(rule => rule.player === actor),
[{ player: actor, turn: done.turn, phase: done.phase }]);
assert.ok(viewGame(done, actor).karamaBlocked, 'the stamped phase ban is publicly visible');
assert.equal(viewGame(done, choam).kullReaction, null);
await restored(f, done);
await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuelCard });
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor });
await stationary(f, done);
});
for (const advanced of [false, true])
void test(`SQLite ${advanced ? 'Advanced' : 'Basic'} distinct held Karama prevents the declared Nexus Kull, retains the fuel and leaves the spent Nexus spent while resuming the original attempt once`, async t => {
const f = await persisted(t, { advanced });
const { choam, actor, other, event, original, fuel, counter } = f;
assert.ok(counter && other);
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
const views = await restored(f, declared);
assert.deepEqual(views[2].responseControls?.cancelCards, [counter],
'only the physically distinct held Karama may prevent the declaration');
assert.equal(views[2].responseControls?.hasPassed, false);
assert.equal((views[1].responseControls?.cancelCards ?? []).includes(original), false,
'the reserved original cannot pay for its own prevention');
assert.equal(views[0].responseControls?.cancelCards.length, 0);
assert.equal(views[1].players.find(p => p.id === choam)?.hand, undefined, 'the declared fuel stays private to CHOAM');
assert.equal(views[1].kullReaction ?? null, null);
await stationary(f, declared);
await rejected(f, declared, actor, { type: 'card', mode: 'cancel', card: original });
await rejected(f, declared, choam, { type: 'card', mode: 'cancel', card: fuel });
await rejected(f, declared, other, { type: 'card', mode: 'cancel', card: `${counter}-stale` });
const done = await race(f, declared, other,
{ type: 'card', mode: 'cancel', card: counter },
{ type: 'card', mode: 'cancel', card: counter });
assert.equal(done.response ?? null, null);
assert.equal(done.pendingKull ?? null, null);
assert.equal(done.karamaShipping?.card, original, 'the interrupted shipment resumes exactly once');
assert.equal(done.karamaShipping?.owner, actor);
assert.equal(done.discard.filter(card => card.id === original).length, 1);
assert.equal(done.discard.filter(card => card.id === counter).length, 1);
assert.equal(player(done, choam).hand.some(card => card.id === fuel), true,
'a prevented declaration retains its declared fuel');
assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam'],
'a canceled declaration leaves the accepted Nexus spend spent');
assert.equal(done.nexusChoamHistory?.length, 1);
assert.equal(done.nexusChoamHistory![0].stage, 'canceled');
assert.equal(done.nexusChoamLast?.stage, 'canceled');
assert.deepEqual(done.kullRestrictions ?? [], [], 'a prevented declaration bans no Karama phase');
await restored(f, done);
await rejected(f, done, other, { type: 'card', mode: 'cancel', card: counter });
await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor });
await stationary(f, done);
});
void test('SQLite declining the offered Nexus Kull spends neither fuel nor Nexus and resumes the exact original attempt once', async t => {
const f = await persisted(t);
const { choam, actor, event, original, fuel } = f;
await restored(f, f.initial);
const done = await act(f, f.initial, choam, { type: 'kullDecision', event, decline: true });
assert.equal(done.response ?? null, null);
assert.equal(done.pendingKull ?? null, null);
assert.equal(done.nexusChoamHistory ?? null, null);
assert.equal(done.nexusChoamLast ?? null, null);
assert.equal(done.nexusCards!.cards!.hands[choam], 'choam', 'a declined offer spends no Nexus');
assert.equal(done.nexusCards!.cards!.discard.includes('choam'), false);
assert.equal(player(done, choam).hand.some(card => card.id === fuel), true);
assert.equal(done.discard.some(card => card.id === fuel), false, 'a declined offer discards no fuel');
assert.equal(done.karamaShipping?.card, original, 'the interrupted shipment resumes exactly once');
assert.equal(done.discard.filter(card => card.id === original).length, 1);
assert.deepEqual(done.kullRestrictions ?? [], []);
await restored(f, done);
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor });
await stationary(f, done);
});
void test('SQLite a successful Nexus Kull intercepts the BG Worthless substitution before conversion and keeps every attempted physical card', async t => {
const f = await persisted(t, { bg: true });
const { choam, actor, event, original, fuel } = f;
assert.equal(f.initial.pendingKull?.form, 'substitution');
assert.equal(f.initial.pendingKarama ?? null, null);
assert.equal(player(f.initial, actor).hand.some(card => card.id === original), true);
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
assert.equal(declared.pendingKarama ?? null, null, 'the Kull counter window precedes BG conversion');
assert.equal(player(declared, actor).hand.some(card => card.id === original), true);
assert.equal(declared.discard.some(card => card.id === original), false);
await restored(f, declared);
const done = await settle(f, declared);
assert.equal(done.pendingKarama ?? null, null);
assert.equal(done.karamaShipping ?? null, null);
assert.deepEqual(player(done, actor).hand, player(f.initial, actor).hand,
'the intercepted BG Worthless card is never converted or discarded');
assert.equal(done.discard.some(card => card.id === original), false);
assert.equal(done.discard.filter(card => card.id === fuel).length, 1);
assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam']);
assert.equal(done.nexusChoamLast?.stage, 'complete');
await restored(f, done);
await stationary(f, done);
await rejected(f, done, actor, { type: 'card', mode: 'shipment', card: original, target: actor });
});
void test('SQLite declining the offered Nexus Kull resumes the real BG conversion and shipment without repeated disposal', async t => {
const f = await persisted(t, { bg: true });
const { choam, actor, event, original, fuel } = f;
const declined = await act(f, f.initial, choam, { type: 'kullDecision', event, decline: true });
assert.equal(declined.pendingKull ?? null, null);
assert.equal(declined.nexusCards!.cards!.discard.includes('choam'), false, 'a declined offer spends no Nexus');
assert.equal(declined.response?.kind, 'worthlessKarama');
assert.ok(declined.pendingKarama, 'declining resumes the real Worthless conversion');
assert.equal(declined.discard.filter(card => card.id === original).length, 1);
assert.equal(player(declined, actor).hand.some(card => card.id === original), false);
await restored(f, declined);
const done = await settle(f, declined);
assert.equal(done.pendingKarama ?? null, null);
assert.equal(done.karamaShipping?.card, original);
assert.equal(done.karamaShipping?.owner, actor);
assert.equal(done.discard.filter(card => card.id === original).length, 1, 'the conversion disposes the card only once');
assert.equal(done.nexusCards!.cards!.discard.includes('choam'), false);
assert.equal(done.nexusChoamHistory ?? null, null);
await restored(f, done);
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, done, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await stationary(f, done);
});
void test('SQLite stale, foreign, malformed and replayed Nexus Kull choices reject before writes and preserve real credentials', async t => {
const f = await persisted(t);
const { choam, actor, other, event, original, fuel } = f;
assert.ok(other);
const before = row(f);
const foreign = await f.restart().authenticate(f.unrelatedCode, f.unrelatedToken);
await assert.rejects(f.restart().authenticate(f.code, f.unrelatedToken));
await assert.rejects(f.restart().readSeatView(f.code, foreign));
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
protectedRows(f);
await rejected(f, f.initial, actor, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, f.initial, other, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel }, f.initial.version - 1);
await rejected(f, f.initial, choam, { type: 'kullDecision', event: `${event}-stale`, source: 'nexus', card: fuel });
await rejected(f, f.initial, choam, { type: 'kullDecision', event, decline: true }, f.initial.version - 1);
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, card: fuel }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'printed', card: fuel }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: original }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: `${fuel}-forged` }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: fuel, target: other }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, source: 'nexus', card: fuel, effect: 'kulon' }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, decline: true, card: fuel }));
await rejected(f, f.initial, choam, forged({ type: 'kullDecision', event, decline: true, source: 'nexus' }));
await rejected(f, f.initial, actor, { type: 'card', mode: 'special', card: original, amount: 2 });
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await restored(f, declared);
await rejected(f, declared, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, declared, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, declared, other, forged({ type: 'card', mode: 'cancel', card: original }));
});
void test('SQLite competing decline and Nexus declaration of one saved Kull offer commit exactly one original outcome', async t => {
const f = await persisted(t);
const { choam, actor, event, original, fuel } = f;
await restored(f, f.initial);
const done = await race(f, f.initial, choam,
{ type: 'kullDecision', event, decline: true },
{ type: 'kullDecision', event, source: 'nexus', card: fuel });
const declared = done.nexusChoamHistory !== undefined;
if (declared) {
assert.equal(done.pendingKull?.stage, 'counter');
assert.deepEqual(done.pendingKull?.selection,
{ source: 'nexus', card: fuel, nexusEvent: done.nexusChoamLast!.event });
assert.equal(done.response?.kind, 'choamWorthless');
assert.equal(player(done, choam).hand.some(card => card.id === fuel), true);
assert.deepEqual(done.nexusCards!.cards!.discard.filter(face => face === 'choam'), ['choam']);
assert.equal(done.nexusChoamLast?.stage, 'pending');
} else {
assert.equal(done.pendingKull ?? null, null);
assert.equal(done.pendingChoamWorthless ?? null, null);
assert.equal(done.karamaShipping?.card, original);
assert.equal(done.nexusCards!.cards!.hands[choam], 'choam');
assert.equal(done.nexusCards!.cards!.discard.includes('choam'), false);
assert.equal(done.nexusChoamLast ?? null, null);
}
assert.equal(done.discard.some(card => card.id === original), !declared);
// A declaration reserves the original in hand; a decline resumes and spends it.
assert.equal(player(done, actor).hand.some(card => card.id === original), declared);
await restored(f, done);
await stationary(f, done);
});
void test('SQLite corrupt or orphaned saved Nexus Kull offers reject all-seat disclosure and settlement before writes', async t => {
const f = await persisted(t);
const { choam, actor, other, event, original, fuel } = f;
assert.ok(other);
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'forged opportunity signature', change: g => { g.pendingKull!.signature = 'forged'; } },
{ name: 'detached opportunity event', change: g => { g.pendingKull!.event += ':stale'; } },
{ name: 'unknown original card', change: g => { g.pendingKull!.card = g.deck[0].id; } },
{ name: 'foreign interrupted owner', change: g => { g.pendingKull!.owner = other; } },
{ name: 'foreign reacting faction', change: g => { g.pendingKull!.player = other; } },
{ name: 'changed opportunity turn', change: g => { g.pendingKull!.turn++; } },
{ name: 'wrong printed form', change: g => { g.pendingKull!.form = 'substitution'; } },
{ name: 'lost sequence', change: g => { g.kullSequence = 0; } },
{ name: 'disabled preview', change: g => { g.kullPreview = false; } },
{ name: 'duplicated original custody', change: g => { g.deck.push(structuredClone(player(g, actor).hand[0])); } },
{ name: 'relocated original custody', change: g => {
const owner = player(g, actor);
const index = owner.hand.findIndex(card => card.id === original);
assert.ok(index >= 0);
g.deck.push(...owner.hand.splice(index, 1));
} },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const saved = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
await rejected(f, corrupt, choam, { type: 'kullDecision', event: corrupt.pendingKull?.event ?? event, decline: true });
await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, corrupt, actor, { type: 'card', mode: 'shipment', card: original, target: actor });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), saved, name);
assert.equal(f.writes.length, 0, name);
protectedRows(f);
}
});
void test('SQLite corrupt or orphaned saved Nexus Kull declarations, receipts and cursors reject all-seat disclosure and settlement before writes', async t => {
const f = await persisted(t);
const { choam, actor, other, event, fuel, counter } = f;
assert.ok(counter && other);
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await restored(f, declared);
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'orphaned declaration without its frame', change: g => { delete g.pendingKull; } },
{ name: 'undeclared selection', change: g => { delete g.pendingKull!.selection; } },
{ name: 'forged counter signature', change: g => { g.pendingKull!.signature += 'x'; } },
{ name: 'detached receipt event', change: g => { delete g.pendingChoamWorthless!.nexusEvent; } },
{ name: 'changed declared card', change: g => { g.pendingChoamWorthless!.card = g.deck[0].id; } },
{ name: 'foreign declaration owner', change: g => { g.pendingChoamWorthless!.owner = other; } },
{ name: 'lost declaration', change: g => { delete g.pendingChoamWorthless; } },
{ name: 'premature completion', change: g => { g.nexusChoamHistory![0].stage = 'complete'; } },
{ name: 'lost history with a saved cursor', change: g => { delete g.nexusChoamHistory; } },
{ name: 'changed receipt card', change: g => { g.nexusChoamHistory![0].receipt.card = g.deck[0].id; } },
{ name: 'forged receipt signature', change: g => { g.nexusChoamHistory![0].signature = 'forged'; } },
{ name: 'changed Nexus turn', change: g => { g.turn++; } },
{ name: 'relocated declared fuel custody', change: g => {
const owner = player(g, choam);
const index = owner.hand.findIndex(card => card.id === fuel);
assert.ok(index >= 0);
g.deck.push(...owner.hand.splice(index, 1));
} },
{ name: 'disabled Nexus preview', change: g => { g.nexusKullPreview = false; } },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(declared);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const saved = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
await rejected(f, corrupt, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, corrupt, other, { type: 'card', mode: 'cancel', card: counter });
await rejected(f, corrupt, actor, { type: 'passResponse' });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), saved, name);
assert.equal(f.writes.length, 0, name);
protectedRows(f);
}
});
void test('SQLite completed Nexus Kull history stays intrinsic after later physical card movement but not after deleted or corrupted evidence', async t => {
const f = await persisted(t);
const { choam, actor, event, original, fuel } = f;
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
const done = await settle(f, declared);
assert.equal(done.nexusChoamLast?.stage, 'complete');
const nexusEvent = done.nexusChoamLast!.event;
const later = structuredClone(done);
const fuelIndex = later.discard.findIndex(card => card.id === fuel);
assert.ok(fuelIndex >= 0);
later.deck.push(...later.discard.splice(fuelIndex, 1));
const cards = later.nexusCards!.cards!;
const nexusIndex = cards.discard.indexOf('choam');
assert.ok(nexusIndex >= 0);
cards.deck.push(...cards.discard.splice(nexusIndex, 1));
f.save(later);
f.writes.length = 0;
const views = await restored(f, later);
assert.equal(views[0].kullReaction ?? null, null, 'a settled opportunity is never reopened by recycling');
assert.ok(views[1].karamaBlocked, 'the stamped phase ban survives the later card movement');
assert.equal(later.nexusChoamHistory!.length, 1, 'history never duplicates an event');
assert.equal(later.nexusChoamHistory![0].stage, 'complete');
assert.deepEqual(later.nexusChoamLast, { event: nexusEvent, stage: 'complete' });
assert.equal(later.deck.filter(card => card.id === fuel).length, 1,
'later recycling moves the spent fuel but cannot invalidate the intrinsic receipt');
await rejected(f, later, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
await rejected(f, later, choam, { type: 'kullDecision', event, decline: true });
await rejected(f, later, actor, { type: 'card', mode: 'shipment', card: original, target: actor });
for (const change of [
(g: Game) => { delete g.nexusChoamHistory; },
(g: Game) => { g.nexusChoamHistory![0].stage = 'pending'; },
(g: Game) => { g.nexusChoamHistory![0].receipt.card = g.deck[0].id; },
(g: Game) => { g.nexusChoamLast!.event += ':stale'; },
(g: Game) => { delete g.nexusChoamLast; },
]) {
const corrupt = structuredClone(later);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const saved = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
assert.deepEqual(row(f), saved);
assert.equal(f.writes.length, 0);
protectedRows(f);
}
});
void test('SQLite a new Nexus-profile printed counter without a selection is corrupt, not a pre-upgrade save', async t => {
const f = await persisted(t);
const { choam, actor, event } = f;
const beforeDeclaration = structuredClone(f.initial);
const cost = beforeDeclaration.deck.find(card => card.id === 'ix-kull-wahad')!;
assert.ok(cost);
beforeDeclaration.deck.splice(beforeDeclaration.deck.indexOf(cost), 1);
player(beforeDeclaration, choam).hand.push(cost);
f.save(beforeDeclaration);
const declared = await act(f, beforeDeclaration, choam,
{ type: 'kullDecision', event, source: 'printed', card: cost.id });
const corrupt = structuredClone(declared);
const signature: Record<string, unknown> = JSON.parse(corrupt.pendingKull!.signature);
delete signature.selection;
delete corrupt.pendingKull!.selection;
corrupt.pendingKull!.signature = JSON.stringify(signature);
f.save(corrupt);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)));
await rejected(f, corrupt, actor, { type: 'passResponse' });
await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'printed', card: cost.id });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before);
assert.equal(f.writes.length, 0);
inventory(f, corrupt);
});
void test('SQLite independently invented or altered Treachery descriptors never become Nexus Kull fuel', async t => {
const f = await persisted(t, { fuel: 'weapon' });
const { choam, event, fuel } = f;
const mutations: { name: string; change(this: void, g: Game): string }[] = [
{ name: 'invented unique identifier', change: g => {
const card = player(g, choam).hand.find(card => card.id === fuel)!;
card.id = 'counterfeit-unique-treachery';
return card.id;
} },
{ name: 'altered canonical name', change: g => {
player(g, choam).hand.find(card => card.id === fuel)!.name = 'Invented Hunter Seeker';
return fuel;
} },
{ name: 'altered canonical kind', change: g => {
player(g, choam).hand.find(card => card.id === fuel)!.kind = 'worthless';
return fuel;
} },
{ name: 'altered canonical effect', change: g => {
player(g, choam).hand.find(card => card.id === fuel)!.effect = 'karama';
return fuel;
} },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(f.initial);
const id = change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = row(f);
const view = await f.restart().readSeatView(f.code, await authenticate(f, choam));
assert.equal(view.kullReaction?.plays.some(play => play.card.id === id), false, name);
await rejected(f, corrupt, choam, { type: 'kullDecision', event, source: 'nexus', card: id });
assert.deepEqual(row(f), before, name);
assert.equal(f.writes.length, 0, name);
protectedRows(f);
}
});
void test('SQLite invented or altered canonical selected fuel cannot project or settle a restored Nexus Kull declaration', async t => {
const f = await persisted(t, { fuel: 'weapon' });
const { choam, actor, other, event, fuel, counter } = f;
assert.ok(other && counter);
const declared = await act(f, f.initial, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
const mutations: { name: string; change(this: void, g: Game): void }[] = [
{ name: 'invented unique identifier', change: g => {
// Deliberate forged evidence keeps all event/cursor references aligned so
// independent physical admission, rather than a detached receipt, rejects it.
Object.assign(g, JSON.parse(JSON.stringify(g).replaceAll(fuel, 'counterfeit-unique-treachery')));
} },
{ name: 'name', change: g => { player(g, choam).hand.find(card => card.id === fuel)!.name += ' forged'; } },
{ name: 'kind', change: g => { player(g, choam).hand.find(card => card.id === fuel)!.kind = 'worthless'; } },
{ name: 'effect', change: g => { player(g, choam).hand.find(card => card.id === fuel)!.effect = 'karama'; } },
];
for (const { name, change } of mutations) {
const corrupt = structuredClone(declared);
change(corrupt);
f.save(corrupt);
f.writes.length = 0;
const before = row(f);
for (const auth of f.auths)
await assert.rejects(f.restart().readSeatView(f.code, await authenticate(f, auth.playerId)), name);
await rejected(f, corrupt, actor, { type: 'passResponse' });
await rejected(f, corrupt, other, { type: 'card', mode: 'cancel', card: counter });
await Promise.allSettled([f.restart().continueRoomAutomatic(f.code, clock)]);
assert.deepEqual(row(f), before, name);
assert.equal(f.writes.length, 0, name);
const expected = name === 'invented unique identifier'
? f.stock.map(id => id === fuel ? 'counterfeit-unique-treachery' : id).sort() : f.stock;
assert.deepEqual(physical(corrupt), expected);
nexusInventory(corrupt);
protectedRows(f);
}
});
for (const outcome of ['success', 'prevention', 'decline'] as const)
void test(`SQLite native Atreides Nexus inspection survives a real suspended Kull ${outcome} and resumes its exact continuation after restart`, async t => {
let native!: NexusChoamKullGame & { reactor: string; reaction: string };
const f = await persisted(t, { actorFaction: 'atreides', fuel: 'weapon' }, ids => {
native = createNexusKullNativeParent('inspection', ids);
return native;
});
const { choam, actor, fuel } = f;
const { reactor, reaction } = native;
const parentResponse = plain(f.initial.response);
const parentInspection = plain(f.initial.battle!.nexusInspection);
const firstAnswer = plain(f.initial.battle!.prescience);
assert.equal(parentResponse?.kind, 'nexusPrescience');
await restored(f, f.initial);
const offered = await act(f, f.initial, reactor, { type: 'card', mode: 'cancel', card: reaction });
const event = offered.pendingKull!.event;
assert.deepEqual(offered.pendingKull!.resume.response, parentResponse);
const offerViews = await restored(f, offered);
assert.equal(offerViews[0].kullReaction?.event, event);
let done: Game;
if (outcome === 'decline') {
done = await act(f, offered, choam, { type: 'kullDecision', event, decline: true });
} else {
const declared = await act(f, offered, choam, { type: 'kullDecision', event, source: 'nexus', card: fuel });
assert.deepEqual(declared.pendingKull!.resume.response, parentResponse);
assert.deepEqual(declared.battle!.nexusInspection, parentInspection);
assert.deepEqual(declared.battle!.prescience, firstAnswer);
await restored(f, declared);
await stationary(f, declared);
if (outcome === 'prevention') {
done = await act(f, declared, actor, { type: 'card', mode: 'cancel', card: f.original });
} else {
done = await settle(f, declared);
assert.equal(player(done, reactor).hand.some(card => card.id === reaction), true);
assert.equal(done.discard.filter(card => card.id === fuel).length, 1);
assert.equal(done.nexusChoamLast?.stage, 'complete');
assert.equal(done.battle!.nexusInspection?.stage, 'answer');
done = await act(f, done, reactor, { type: 'nexusPrescienceAnswer', event: done.battle!.event, value: null });
assert.deepEqual(done.battle!.nexusInspection?.answers, [null]);
}
}
if (outcome !== 'success') {
assert.equal(done.battle!.nexusInspection?.stage, 'canceled');
assert.equal(done.discard.filter(card => card.id === reaction).length, 1);
assert.equal(player(done, choam).hand.some(card => card.id === fuel), true);
assert.equal(done.discard.some(card => card.id === fuel), false);
if (outcome === 'prevention') {
assert.equal(done.discard.filter(card => card.id === f.original).length, 1);
assert.equal(done.nexusChoamLast?.stage, 'canceled');
} else {
assert.equal(done.nexusCards!.cards!.hands[choam], 'choam');
assert.equal(done.nexusChoamLast ?? null, null);
}
}
assert.equal(done.pendingKull ?? null, null);
assert.deepEqual(done.battle!.prescience, firstAnswer);
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'atreides').length, 1);
await restored(f, done);
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
inventory(f, done);
});
void test('SQLite a suspended native CHOAM Karama fuel is never offered to cancel a nested BG Kull counter', async t => {
let nested!: NexusChoamKullGame & { printed: string; bgCounter: string; nexusEvent: string };
const f = await persisted(t, { bg: true, fuel: 'karama' }, ids => {
nested = createNexusKullNestedParent(ids);
return nested;
});
const { choam, actor, original, fuel } = f;
const retainedFuel = plain(player(f.initial, choam).hand.find(card => card.id === fuel));
const parent = plain(f.initial.pendingChoamWorthless);
await restored(f, f.initial);
const offered = await act(f, f.initial, actor, { type: 'card', mode: 'cancel', card: original });
const event = offered.pendingKull!.event;
const declared = await act(f, offered, choam,
{ type: 'kullDecision', event, source: 'printed', card: nested.printed });
assert.deepEqual(declared.pendingKull!.worthless, parent);
await restored(f, declared);
const converted = await act(f, declared, actor, { type: 'card', mode: 'cancel', card: nested.bgCounter });
assert.equal(converted.response?.kind, 'worthlessKarama');
assert.equal(converted.pendingKarama?.use.kind, 'cancel');
const views = await restored(f, converted);
assert.equal(views[0].responseControls?.cancelCards.includes(fuel), false);
assert.equal(views[0].responseControls?.cancelCards.includes(nested.printed), false);
assert.deepEqual(player(converted, choam).hand.find(card => card.id === fuel), retainedFuel);
await rejected(f, converted, choam, { type: 'card', mode: 'cancel', card: fuel });
await stationary(f, converted);
const done = await settle(f, converted);
assert.equal(done.pendingKull ?? null, null);
assert.equal(done.pendingKarama ?? null, null);
assert.equal(done.pendingChoamWorthless ?? null, null);
assert.equal(done.nexusChoamLast?.event, nested.nexusEvent);
assert.equal(done.nexusChoamLast?.stage, 'canceled');
assert.deepEqual(player(done, choam).hand.find(card => card.id === fuel), retainedFuel);
assert.equal(done.discard.some(card => card.id === fuel), false);
assert.equal(player(done, choam).hand.filter(card => card.id === nested.printed).length, 1);
assert.equal(done.discard.filter(card => card.id === nested.bgCounter).length, 1);
assert.equal(done.discard.filter(card => card.id === original).length, 1);
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, 1);
await restored(f, done);
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
});
for (const source of ['printed', 'nexus'] as const)
void test(`SQLite ${source} Kull keeps CHOAM's last winning-payment Karama reserved and the original auction payable after restart`, async t => {
let auction!: NexusChoamKullGame & { payment: string; printed: string; reactor: string; reaction: string; lot: string };
const f = await persisted(t, { fuel: 'weapon' }, ids => {
auction = createNexusKullAuctionParent(ids);
return auction;
});
const { choam, actor, fuel } = f;
const { payment, reactor, reaction, lot, printed } = auction;
assert.equal(f.initial.decision?.kind, 'auctionPayment');
assert.equal(f.initial.auction?.bidder, choam);
assert.equal(f.initial.auction?.bid, 2);
assert.equal(player(f.initial, choam).spice, 1);
await restored(f, f.initial);
const offered = await act(f, f.initial, reactor,
{ type: 'card', mode: 'special', card: reaction, target: actor, amount: 1 });
const event = offered.pendingKull!.event;
const views = await restored(f, offered);
assert.equal(views[0].kullReaction?.plays.some(play => play.card.id === payment), false);
assert.ok(views[0].kullReaction?.plays.some(play => play.source === 'printed' && play.card.id === printed));
assert.ok(views[0].kullReaction?.plays.some(play => play.source === 'nexus' && play.card.id === fuel));
await rejected(f, offered, choam, { type: 'kullDecision', event, source: 'nexus', card: payment });
const cost = source === 'printed' ? printed : fuel;
const declared = await act(f, offered, choam, { type: 'kullDecision', event, source, card: cost });
await restored(f, declared);
await stationary(f, declared);
const prevented = await settle(f, declared);
assert.deepEqual(prevented.decision, { kind: 'auctionPayment', player: choam });
assert.equal(prevented.auction?.bid, 2);
assert.equal(prevented.auction?.cards[prevented.auction.index].id, lot);
assert.equal(player(prevented, choam).spice, 1);
assert.equal(player(prevented, choam).hand.filter(card => card.id === payment).length, 1);
assert.equal(player(prevented, reactor).hand.filter(card => card.id === reaction).length, 1);
assert.equal(prevented.discard.filter(card => card.id === cost).length, 1);
const paid = await act(f, prevented, choam, { type: 'decision', karama: true });
const done = await settle(f, paid);
assert.notEqual(done.decision?.kind, 'auctionPayment');
assert.equal(player(done, choam).hand.filter(card => card.id === lot).length, 1);
assert.equal(done.discard.filter(card => card.id === payment).length, 1);
assert.equal(done.discard.filter(card => card.id === cost).length, 1);
assert.equal(player(done, choam).spice, 1);
assert.equal(done.nexusCards!.cards!.discard.filter(face => face === 'choam').length, source === 'nexus' ? 1 : 0);
await restored(f, done);
await rejected(f, done, choam, { type: 'kullDecision', event, source, card: cost });
inventory(f, done);
});
+33 -13
View File
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CHOAM_NEXUS_EFFECTS,
createNexusChoam,
validateNexusChoam,
type NexusChoamContext,
@@ -19,18 +18,7 @@ const context: NexusChoamContext = {
};
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(),
);
void test('CHOAM Nexus receipts enforce effect timing while leaving Kull response authority to the caller', () => {
for (const [effect, phase] of [
['kulon', 5],
['laLaLa', 4],
@@ -216,3 +204,35 @@ void test('CHOAM receipt creation rejects malformed or foreign inputs before pro
);
assert.deepEqual(context, before);
});
void test('self-consistent saved Kull receipts still reject invalid intrinsic cost, owner and stamps', () => {
const receipt = createNexusChoam(context, 'c', 5, 'physical-weapon-1', 'kull');
const patches: Partial<NexusChoamReceipt>[] = [
{ card: '' },
{ card: ' ' },
{ card: '__proto__' },
{ owner: 'a' },
{ owner: 'missing' },
{ turn: 0 },
{ turn: context.turn + 1 },
{ turn: 1.5 },
{ phase: -1 },
{ phase: 9 },
{ phase: 5.5 },
{ effect: 'unknown' as NexusChoamEffect },
];
for (const patch of patches) {
const invalid = { ...clone(receipt), ...patch };
invalid.event = JSON.stringify([
'nexusChoam', invalid.turn, invalid.phase, invalid.owner, invalid.card, invalid.effect,
]);
invalid.signature = JSON.stringify([
invalid.version, invalid.event, invalid.turn, invalid.phase,
invalid.owner, invalid.card, invalid.effect,
invalid.roster.map(player => [player.id, player.faction]),
]);
const before = clone(invalid);
assert.throws(() => validateNexusChoam(context, invalid));
assert.deepEqual(invalid, before);
}
});
+28
View File
@@ -12,6 +12,7 @@ import {
startIxPrototypeRoom,
startPrototypeRoom,
} from '../tools/prototype-room';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
function fixture(t: test.TestContext) {
const db = new DatabaseSync(':memory:');
@@ -301,3 +302,30 @@ void test('Richese Betrayal profile initializes one fresh paired Nexus game and
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
for (const advanced of [false, true]) {
void test(`Nexus Kull ${advanced ? 'Advanced' : 'Basic'} entry preserves canonical inventories and cannot redeal a saved game`, (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('c', 'CHOAM', 'choam'), advanced, ['choam', 'ix']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const p of game.players) p.ready = true;
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
const seats = db.prepare('SELECT * FROM seats').all();
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
startPrototypeRoom(db, game.code, 7, 'nexus-kull');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(saved.status, 'setup');
assert.equal(saved.nexusKullPreview, true);
assert.equal(saved.kullPreview, true);
assert.deepEqual([...saved.nexusCards!.cards!.deck].sort((a,b) => a.localeCompare(b)),
[...NEXUS_FACTIONS].sort((a,b) => a.localeCompare(b)));
const cards = [...saved.deck, ...saved.players.flatMap(p => p.hand), ...(saved.ixSetupCards ?? [])];
assert.equal(cards.filter(card => card.id === 'ix-kull-wahad').length, 1);
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'nexus-kull'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'nexus-kull'));
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
}
+1 -1
View File
@@ -216,7 +216,7 @@ void test('real Kull offer gives CHOAM public decision ownership before the susp
const saved = JSON.parse(JSON.stringify(offered)) as Game;
assert.deepEqual(owners(saved), owners(offered));
const counter = applyAction(saved, choam, {
type: 'kullDecision', event: saved.pendingKull!.event, card: 'ix-kull-wahad',
type: 'kullDecision', event: saved.pendingKull!.event, source: 'printed', card: 'ix-kull-wahad',
});
assert.equal(counter.response?.kind, 'choamWorthless');
assert.deepEqual(owners(counter), counter.players.map(() => null));