Add opt-in printed Kull interception and saved counter continuation

This commit is contained in:
Quintine committed 2026-09-30 21:08:33 +10:00
1 parent 3285172eec
commit aeae152370
32 files changed
+2487 -146

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
void test('every profile uses the projected physical Kull and cannot act from a foreign offer', () => {
for (const profile of DIFFICULTIES) {
const original = choamKullGame();
original.players[0].bot = profile;
original.deck.push(...original.players[2].hand);
original.players[2].hand = [];
original.players[3].bot = profile;
const attemptedCard = original.players[1].hand[0].id;
const saved = JSON.parse(JSON.stringify(applyAction(original, 'e', kullShipmentAttempt(original))));
const own = viewGame(saved, 'c');
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.deepEqual(actions, [{ type: 'kullDecision', event: own.kullReaction!.event, 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.
const h = viewGame(settled, 'h');
assert.deepEqual(botActions(h), [{ type: 'passResponse' }]);
settled = applyAction(settled, 'h', botActions(h)[0]);
assert.equal(settled.pendingKull, null);
assert.equal(settled.karamaShipping, null);
assert.ok(settled.players[1].hand.some(card => card.id === attemptedCard));
assert.ok(settled.discard.some(card => card.id === 'ix-kull-wahad'));
assert.ok(viewGame(settled, 'e').karamaBlocked);
}
});
void test('every profile declines no-cost and allied offers and resumes the original shipment once after saving', () => {
for (const profile of DIFFICULTIES) {
for (const allied of [false, true]) {
const game = choamKullGame();
game.players[0].bot = profile;
if (allied) {
game.players[0].ally = 'e';
game.players[1].ally = 'c';
} else {
game.deck.push(takeKullCard(game, 'ix-kull-wahad'));
}
const attemptedCard = game.players[1].hand[0].id;
const offer = JSON.parse(JSON.stringify(applyAction(game, 'e', kullShipmentAttempt(game))));
const own = viewGame(offer, 'c');
const action = botActions(own)[0];
assert.deepEqual(action, { type: 'kullDecision', event: own.kullReaction!.event, decline: true });
const resumed = applyAction(offer, 'c', action);
assert.equal(resumed.pendingKull, null);
assert.equal(resumed.karamaShipping?.owner, 'e');
assert.equal(resumed.karamaShipping?.player, 'e');
assert.equal(resumed.discard.filter(card => card.id === attemptedCard).length, 1);
assert.ok(!resumed.players[1].hand.some(card => card.id === attemptedCard));
}
}
});
void test('every profile counters Kull only with a distinct projected card and resumes the reserved original', () => {
for (const profile of DIFFICULTIES) {
const game = choamKullGame();
game.players[1].bot = profile;
const reserved = game.players[1].hand[0].id;
const counter = takeKullCard(game, game.players[3].hand[0].id);
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',
});
const own = viewGame(JSON.parse(JSON.stringify(declared)), 'e');
assert.ok(!own.responseControls!.cancelCards.includes(reserved));
const actions = botActions(own);
assert.deepEqual(actions, [{ type: 'card', mode: 'cancel', card: counter.id }]);
const resumed = applyAction(declared, 'e', actions[0]);
assert.equal(resumed.pendingKull, null);
assert.equal(resumed.karamaShipping?.owner, 'e');
for (const id of [reserved, counter.id])
assert.equal(resumed.discard.filter(card => card.id === id).length, 1);
assert.ok(resumed.players[0].hand.some(card => card.id === 'ix-kull-wahad'));
assert.equal(viewGame(resumed, 'e').karamaBlocked, null);
}
});
void test('a blocked projected cost is declined rather than treated as a playable held card', () => {
for (const profile of DIFFICULTIES) {
const game = choamKullGame();
game.players[0].bot = profile;
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', {
get() { throw new Error('Read ineligible cost choices'); },
});
const action = botActions(own)[0];
assert.deepEqual(action, { type: 'kullDecision', event: own.kullReaction!.event, decline: true });
const resumed = applyAction(offer, 'c', action);
assert.equal(resumed.karamaShipping?.owner, 'e');
assert.ok(resumed.players[0].hand.some(card => card.id === 'ix-kull-wahad'));
}
});
void test('BG counter conversion keeps all profiles in standard response controls until the saved attempt resumes', () => {
for (const profile of DIFFICULTIES) {
const game = choamKullGame();
game.players[2].bot = profile;
game.players[3].bot = profile;
game.players[2].ally = 'e';
game.players[1].ally = 'b';
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',
});
const action = botActions(viewGame(declared, 'b'))[0];
assert.deepEqual(action, { type: 'card', mode: 'cancel', card: counter });
const conversion = applyAction(declared, 'b', action);
assert.equal(conversion.response?.kind, 'worthlessKarama');
const saved = JSON.parse(JSON.stringify(conversion));
const own = viewGame(saved, 'h');
assert.equal(own.kullCounterEvent, offer.pendingKull!.event);
assert.deepEqual(botActions(own), [{ type: 'passResponse' }]);
const resumed = applyAction(saved, 'h', botActions(own)[0]);
assert.equal(resumed.pendingKull, null);
assert.equal(resumed.karamaShipping?.owner, 'e');
assert.equal(resumed.discard.filter(card => card.id === counter).length, 1);
assert.ok(resumed.players[0].hand.some(card => card.id === 'ix-kull-wahad'));
}
});
+3 -3
View File
@@ -229,13 +229,13 @@ void test('sealed and prescience-committed cards cannot be cashed in or used as
revealed: false,
traitorCalls: {},
};
assert.throws(() => cash(g, [shield]), /uncommitted/);
assert.throws(() => cash(g, [shield]));
assert.ok(!viewGame(g, 'c').choamCashIn?.cards.some((c) => c.id === shield));
g.battle.plans = {};
g.battle.prescience = { player: 'e', field: 'defense', value: shield };
assert.throws(() => cash(g, [shield]), /uncommitted/);
assert.throws(() => cash(g, [shield]));
g.battle.prescience.value = activation(g);
assert.throws(() => cash(g, [named(g, 'Baliset')]), /uncommitted/);
assert.throws(() => cash(g, [named(g, 'Baliset')]));
});
void test('only CHOAM sees its cash-in choices; used status and state survive persistence', () => {
let g = fixture();
+111
View File
@@ -0,0 +1,111 @@
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 { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
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 component imports would resolve Next modules before the Node alias hook.
const { ChoamKull } = await import('../components/choam-kull');
const { GameTable } = await import('../components/game-table');
function offerView(owner = 'c'): GameView {
const game = choamKullGame();
const offer = applyAction(game, 'e', kullShipmentAttempt(game));
return viewGame(JSON.parse(JSON.stringify(offer)), owner);
}
function markup(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(ChoamKull, { game, act() {}, busy }));
}
void test('only the reactor receives physical Kull cost controls; outsiders cannot inspect offer cards', () => {
const holder = offerView();
const rival = offerView('h');
for (const player of rival.players) {
if (player.id === rival.me) continue;
Object.defineProperty(player, 'hand', {
get() { throw new Error('Opponent private hand read'); },
});
}
const html = markup(holder);
assert.match(html, /<button[^>]*>Decline Kull/);
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/);
// Even an accidentally overfilled foreign projection must not create controls.
rival.kullReaction = holder.kullReaction;
assert.doesNotMatch(markup(rival), /<button|ix-kull-wahad|Inspect card/);
});
void test('busy and blocked offers cannot expose an enabled use action, but an unavailable cost can be declined', () => {
const holder = offerView();
const busy = markup(holder, true);
assert.match(busy, /disabled=""[^>]*>Decline Kull/);
assert.match(busy, /disabled=""[^>]*>Use Kull Wahad/);
holder.kullReaction!.blocked = 'The physical cost is reserved.';
assert.doesNotMatch(markup(holder), />Use Kull Wahad/);
assert.match(markup(holder), /<button[^>]*>Decline Kull/);
holder.kullReaction!.blocked = null;
holder.kullReaction!.cards = [];
assert.doesNotMatch(markup(holder), />Use Kull Wahad/);
assert.match(markup(holder), /<button[^>]*>Decline Kull/);
holder.kullReaction!.canDecline = false;
assert.doesNotMatch(markup(holder), /<button/);
});
void test('the real saved table mounts the offer instead of shipment and voluntary hand powers', () => {
const holder = offerView();
const html = renderToStaticMarkup(createElement(GameTable, {
game: holder, send: async () => {}, busy: false, onExit() {},
}));
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() {},
}));
assert.match(disabled, /disabled=""[^>]*>Use Kull Wahad/);
});
void test('a successful phase ban removes the retained Karama shipment-rate control', () => {
const initial = choamKullGame();
let g = applyAction(initial, 'e', kullShipmentAttempt(initial));
g = applyAction(g, 'c', {
type: 'kullDecision', event: g.pendingKull!.event, card: 'ix-kull-wahad',
});
for (let step = 0; g.pendingKull && step < 12; step++) {
const actor = g.players.find(p => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(actor);
g = applyAction(g, actor.id, { type: 'passResponse' });
}
assert.equal(g.pendingKull, null);
const blocked = viewGame(g, 'e');
const render = (game: GameView) => renderToStaticMarkup(createElement(GameTable, {
game, send: async () => { assert.fail('Rendering must not submit gameplay.'); }, busy: false, onExit() {},
}));
assert.ok(blocked.karamaBlocked);
assert.doesNotMatch(render(blocked), />Guild rates for/);
const available = { ...blocked, karamaBlocked: null };
assert.match(render(available), />Guild rates for/);
});
aliases.deregister();
+625
View File
@@ -0,0 +1,625 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
const held = (g: Game, id: string, card: string) => player(g, id).hand.some(c => c.id === card);
function inventory(g: Game) {
const ids = [...g.deck, ...g.discard, ...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 inventory has no duplicate cards');
return ids;
}
function declare(g: Game) {
const reaction = viewGame(g, 'c').kullReaction!;
return applyAction(reload(g), 'c', { type: 'kullDecision', event: reaction.event, card: 'ix-kull-wahad' });
}
function finishKull(g: Game) {
for (let step = 0; g.pendingKull && step < 12; step++) {
const responder = g.players.find(p => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
g = responder ? applyAction(reload(g), responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(reload(g));
}
assert.equal(g.pendingKull, null, 'counter response settles without re-opening the offer');
return g;
}
function finishConversion(g: Game, kind: NonNullable<Game['response']>['kind'] = 'worthlessKarama') {
for (let step = 0; g.response?.kind === kind && step < 12; step++) {
const responder = g.players.find(p => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
g = responder ? applyAction(reload(g), responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(reload(g));
}
assert.notEqual(g.response?.kind, kind);
return g;
}
function auction(g: Game, bid: number, payment = true) {
const lot = g.deck.shift()!;
Object.assign(g, { phase: 3, active: 'e', movementRemaining: [],
auction: { cards: [lot], index: 0, bid, bidder: payment ? 'e' : null,
active: 'e', passed: [], opener: 0 },
decision: payment ? { kind: 'auctionPayment', player: 'e' } : null });
return lot;
}
void test('ordinary printed attempts offer neutral CHOAM timing before cost with private projected choices', () => {
const g = choamKullGame(), absent = reload(g);
const replacement = absent.deck.shift()!;
absent.deck.push(...player(absent, 'c').hand);
player(absent, 'c').hand = [replacement];
const action = kullShipmentAttempt(g), original = action.card as string;
const offered = applyAction(g, 'e', action), neutral = applyAction(absent, 'e', action);
assert.deepEqual(viewGame(offered, 'e'), viewGame(neutral, 'e'));
assert.deepEqual(viewGame(offered, 'h'), viewGame(neutral, 'h'));
assert.equal(offered.karamaShipping, null);
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.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(normalizeAutomaticGame(reload(offered)), reload(offered));
const event = offered.pendingKull!.event;
for (const [id, bad] of [
['h', { type: 'kullDecision', event, decline: true }],
['c', { type: 'kullDecision', event: 'stale', decline: true }],
['c', { type: 'kullDecision', event, decline: true, card: 'ix-kull-wahad' }],
['e', { type: 'ready' }],
] as const) {
const before = JSON.stringify(offered);
assert.throws(() => applyAction(offered, id, bad));
assert.equal(JSON.stringify(offered), before);
}
const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event, decline: true });
assert.deepEqual(declined.karamaShipping, { player: 'e', owner: 'e', card: original });
assert.equal(declined.discard.filter(card => card.id === original).length, 1);
assert.ok(held(declined, 'c', 'ix-kull-wahad'));
assert.throws(() => applyAction(declined, 'c', { type: 'kullDecision', event, decline: true }));
});
void test('successful Kull retains original card, spends exact cost and blocks activations only until phase expiry', () => {
const g = choamKullGame(), cards = inventory(g), original = player(g, 'e').hand[0].id;
const starting = g.players.map(p => p.spice);
const result = finishKull(declare(applyAction(g, 'e', kullShipmentAttempt(g))));
assert.equal(result.karamaShipping, null);
assert.ok(held(result, 'e', original));
assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
assert.deepEqual(result.players.map(p => p.spice), starting);
assert.deepEqual(inventory(result), cards);
assert.throws(() => applyAction(result, 'e', kullShipmentAttempt(result)), /Kull Wahad/);
const expired = reload(result);
expired.turn++;
expired.karamaShipping = null;
const retry = applyAction(expired, 'e', kullShipmentAttempt(expired));
assert.equal(retry.pendingKull?.stage, 'offer');
assert.ok(held(retry, 'e', original));
});
void test('different physical card counter works for the interrupted actor and cannot reuse the reserved original', () => {
const g = choamKullGame(), original = player(g, 'e').hand[0].id;
const counter = takeKullCard(g, player(g, 'h').hand[0].id);
player(g, 'e').hand.push(counter);
const cards = inventory(g);
const window = declare(applyAction(g, 'e', kullShipmentAttempt(g)));
assert.deepEqual(viewGame(window, 'e').responseControls?.cancelCards, [counter.id]);
const before = JSON.stringify(window);
assert.throws(() => applyAction(window, 'e', { type: 'card', mode: 'cancel', card: original }), /reserved/);
assert.equal(JSON.stringify(window), before);
const result = applyAction(reload(window), 'e', { type: 'card', mode: 'cancel', card: counter.id });
assert.equal(result.pendingKull, null);
assert.deepEqual(result.karamaShipping, { player: 'e', owner: 'e', card: original });
assert.ok(held(result, 'c', 'ix-kull-wahad'));
assert.equal(result.discard.filter(card => card.id === counter.id).length, 1);
assert.equal(result.discard.filter(card => card.id === original).length, 1);
assert.deepEqual(inventory(result), cards);
assert.equal(result.choamWorthlessBlocked?.cards.includes('ix-kull-wahad'), true);
assert.equal(viewGame(result, 'e').karamaBlocked, null);
});
void test('BG original is intercepted before conversion and retained on success; decline converts once', () => {
const g = choamKullGame();
g.active = 'b';
const action = kullShipmentAttempt(g, 'b'), original = action.card as string;
const offered = applyAction(g, 'b', action);
assert.equal(offered.pendingKarama, null);
assert.ok(held(offered, 'b', original));
const result = finishKull(declare(offered));
assert.ok(held(result, 'b', original));
assert.equal(result.pendingKarama, null);
assert.equal(result.karamaShipping, null);
assert.throws(() => applyAction(result, 'b', action), /Kull Wahad/);
const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
assert.equal(declined.response?.kind, 'worthlessKarama');
assert.equal(declined.discard.filter(card => card.id === original).length, 1);
const completed = finishConversion(declined);
assert.deepEqual(completed.karamaShipping, { player: 'b', owner: 'b', card: original });
assert.equal(completed.discard.filter(card => card.id === original).length, 1);
});
void test('BG distinct substitution can counter Kull while original remains held until its one resumed commit', () => {
const g = choamKullGame(), original = player(g, 'e').hand[0].id;
const window = declare(applyAction(g, 'e', kullShipmentAttempt(g)));
const bg = player(g, 'b').hand[0].id;
const conversion = applyAction(reload(window), 'b', { type: 'card', mode: 'cancel', card: bg });
assert.equal(conversion.response?.kind, 'worthlessKarama');
assert.equal(conversion.pendingKull?.stage, 'counter');
for (const id of ['c', 'e', 'b', 'h']) {
const view = viewGame(conversion, id);
assert.equal(view.kullCounterEvent, window.pendingKull!.event);
assert.equal(view.kullReaction, null);
if (id !== 'c') assert.equal(view.choamWorthless, null);
}
assert.ok(held(conversion, 'e', original));
const result = finishConversion(conversion);
assert.equal(result.pendingKull, null);
assert.equal(viewGame(result, 'b').kullCounterEvent, null);
assert.ok(held(result, 'c', 'ix-kull-wahad'));
assert.equal(result.discard.filter(card => card.id === bg).length, 1);
assert.equal(result.discard.filter(card => card.id === original).length, 1);
assert.deepEqual(result.karamaShipping, { player: 'e', owner: 'e', card: original });
});
void test('special Emperor decline/counter commits revival once; Kull success leaves special use and resources unspent', () => {
const g = choamKullGame();
g.phase = 4;
player(g, 'e').tanks = 4;
player(g, 'e').reserves -= 4;
const original = player(g, 'e').hand[0].id, reserves = player(g, 'e').reserves;
const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original, amount: 2 });
assert.equal(player(offered, 'e').reserves, reserves);
assert.equal(player(offered, 'e').specialKaramaUsed, undefined);
const event = offered.pendingKull!.event;
const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event, decline: true });
assert.equal(player(declined, 'e').reserves, reserves + 2);
assert.equal(player(declined, 'e').tanks, 2);
assert.equal(player(declined, 'e').specialKaramaUsed, true);
const canceled = applyAction(declare(offered), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
assert.equal(player(canceled, 'e').reserves, reserves + 2);
assert.equal(player(canceled, 'e').tanks, 2);
const prevented = finishKull(declare(offered));
assert.equal(player(prevented, 'e').reserves, reserves);
assert.equal(player(prevented, 'e').tanks, 4);
assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
assert.ok(held(prevented, 'e', original));
assert.throws(() => applyAction(prevented, 'e', { type: 'card', mode: 'special', card: original, amount: 2 }), /Kull Wahad/);
});
void test('ordinary cancellation preserves the original CHOAM Worthless parent across Kull success and counter', () => {
const g = choamKullGame();
const la = takeKullCard(g, 'La La La');
player(g, 'c').hand.push(la);
g.phase = 4;
const parent = applyAction(g, 'c', { type: 'card', mode: 'choam', card: la.id, target: 'e' });
const original = player(g, 'e').hand[0].id;
const offered = applyAction(parent, 'e', { type: 'card', mode: 'cancel', card: original });
assert.equal(viewGame(offered, 'h').response, null);
assert.equal(offered.pendingChoamWorthless?.card, la.id);
let success = finishKull(declare(offered));
assert.equal(success.pendingChoamWorthless?.card, la.id);
assert.equal(success.response?.intent, 'La La La');
success = finishConversion(success, 'choamWorthless');
assert.ok(held(success, 'e', original));
assert.equal(success.revivalRules?.freeBlocked?.includes('e'), true);
assert.equal(success.discard.filter(card => card.id === la.id).length, 1);
const canceled = applyAction(declare(offered), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
assert.ok(held(canceled, 'c', la.id));
assert.equal(canceled.revivalRules?.freeBlocked?.includes('e') ?? false, false);
assert.equal(canceled.discard.filter(card => card.id === original).length, 1);
});
void test('funded payment and free purchase resume once; unfunded overbid guard is hidden-Kull independent and immutable', () => {
for (const payment of [true, false]) {
const g = choamKullGame(), lot = auction(g, payment ? 2 : 0, payment), original = player(g, 'e').hand[0].id;
const action = payment ? { type: 'decision', karama: true, card: original } : { type: 'card', mode: 'purchase', card: original };
const offered = applyAction(g, 'e', action);
assert.equal(held(offered, 'e', lot.id), false);
const completed = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
assert.ok(held(completed, 'e', lot.id));
assert.equal(completed.discard.filter(card => card.id === original).length, 1);
assert.equal(player(completed, 'e').spice, player(g, 'e').spice);
}
const g = choamKullGame();
auction(g, player(g, 'e').spice + 1);
const absent = reload(g);
absent.deck.push(...player(absent, 'c').hand);
player(absent, 'c').hand = [];
for (const state of [g, absent]) {
const before = JSON.stringify(state);
assert.throws(() => applyAction(state, 'e', { type: 'decision', karama: true, card: player(state, 'e').hand[0].id }), /defers/);
assert.equal(JSON.stringify(state), before);
assert.equal(state.pendingKull, undefined);
}
});
void test('invalid original and corrupted saved frame reject before public timing and without mutation', () => {
const g = choamKullGame();
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, 'e', { type: 'card', mode: 'shipment', card: player(g, 'e').hand[0].id, target: 'h' }));
assert.equal(JSON.stringify(g), before);
const offered = applyAction(g, 'e', kullShipmentAttempt(g));
const corrupt = reload(offered);
corrupt.pendingKull!.intent = { kind: 'ordinary', use: { kind: 'shipment', recipient: 'h' } };
const saved = JSON.stringify(corrupt);
assert.throws(() => normalizeAutomaticGame(corrupt), /saved Kull attempt/);
assert.throws(() => viewGame(corrupt, 'c'), /saved Kull attempt/);
assert.throws(() => applyAction(corrupt, 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true }));
assert.equal(JSON.stringify(corrupt), saved);
});
void test('Basic preview supports printed ordinary interception without enabling Advanced substitution or special powers', () => {
const g = choamKullGame({ advanced: false });
const original = player(g, 'e').hand[0].id;
const offered = applyAction(g, 'e', kullShipmentAttempt(g));
assert.equal(viewGame(offered, 'c').kullPreview, true);
const result = finishKull(declare(offered));
assert.ok(held(result, 'e', original));
assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
const bg = reload(g);
bg.active = 'b';
assert.throws(() => applyAction(bg, 'b', { type: 'card', mode: 'shipment', card: player(bg, 'b').hand[0].id }));
});
void test('Harkonnen interrupted random exchange draws no RNG until decline and preserves the private return parent', t => {
const g = choamKullGame();
g.phase = 3;
player(g, 'e').hand.push(takeKullCard(g, 'Shield'));
player(g, 'h').hand.push(takeKullCard(g, 'Snooper'));
const original = player(g, 'h').hand.find(card => card.effect === 'karama')!.id;
let draws = 0;
t.mock.method(crypto, 'getRandomValues', (array: Uint32Array) => {
draws++;
array.fill(0);
return array;
});
const offered = applyAction(g, 'h', { type: 'card', mode: 'special', card: original, target: 'e', amount: 1 });
assert.equal(draws, 0);
assert.deepEqual(player(offered, 'e').hand, player(g, 'e').hand);
const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
assert.equal(draws, 1);
assert.equal(declined.decision?.kind, 'handExchange');
assert.equal(player(declined, 'h').specialKaramaUsed, true);
assert.equal(declined.discard.filter(card => card.id === original).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(declined)), reload(declined));
assert.equal(draws, 1);
assert.equal(viewGame(declined, 'e').harkonnenExchangeInspection, null);
assert.throws(() => applyAction(declined, 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true }));
assert.equal(draws, 1);
});
void test('Fremen special allowance is intercepted before worm appearance and canceled Kull cannot replay its card', () => {
const g = choamKullGame({ opponents: ['fremen', 'emperor', 'harkonnen'] });
g.phase = 1;
const original = player(g, 'e').hand[0].id;
const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original, territory: 'hagga_basin' });
assert.equal(offered.summonedWorm ?? null, null);
assert.ok(held(offered, 'e', original));
const prevented = finishKull(declare(offered));
assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
assert.equal(prevented.summonedWorm ?? null, null);
assert.ok(held(prevented, 'e', original));
const resumed = applyAction(declare(offered), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
assert.equal(player(resumed, 'e').specialKaramaUsed, true);
assert.equal(resumed.discard.filter(card => card.id === original).length, 1);
assert.equal(resumed.pendingKull, null);
assert.throws(() => applyAction(resumed, 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true }));
});
void test('Guild special prevention keeps the unpaid shipment parent on Kull success and stops it once on decline', () => {
let g = choamKullGame({ opponents: ['guild', 'emperor', 'harkonnen'] });
g.active = 'b';
g.movementRemaining = ['b', 'e', 'h', 'c'];
const before = { spice: player(g, 'b').spice, reserves: player(g, 'b').reserves };
g = applyAction(g, 'b', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2 });
assert.equal(g.decision?.kind, 'guildShipment');
const original = player(g, 'e').hand[0].id;
const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original });
assert.deepEqual({ spice: player(offered, 'b').spice, reserves: player(offered, 'b').reserves }, before);
assert.equal(viewGame(offered, 'b').decision, null);
const prevented = finishKull(declare(offered));
assert.equal(prevented.decision?.kind, 'guildShipment');
assert.equal(prevented.pendingShipment?.player, 'b');
assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
assert.deepEqual({ spice: player(prevented, 'b').spice, reserves: player(prevented, 'b').reserves }, before);
const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
assert.equal(declined.pendingShipment, null);
assert.equal(player(declined, 'b').shipped, true);
assert.equal(player(declined, 'e').specialKaramaUsed, true);
assert.equal(declined.discard.filter(card => card.id === original).length, 1);
assert.deepEqual({ spice: player(declined, 'b').spice, reserves: player(declined, 'b').reserves }, before);
});
void test('Atreides special prescience retains its pre-plan offer until the intercepted card is allowed', () => {
let g = choamKullGame({ opponents: ['atreides', 'emperor', 'harkonnen'] });
g.phase = 6;
g.active = 'e';
for (const p of g.players) { p.forces = {}; p.reserves = 20; }
for (const id of ['e', 'b']) { player(g, id).forces = { 'arrakeen:10': 2 }; player(g, id).reserves = 18; }
g = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'b' });
for (let step = 0; (g.response || g.battle?.preparation) && step < 20; step++) {
if (g.response) {
const responder = g.players.find(p => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
g = responder ? applyAction(g, responder.id, { type: 'passResponse' }) : normalizeAutomaticGame(g);
} else g = applyAction(g, g.battle!.preparation!.owner, { type: 'declineBattlePower' });
}
assert.equal(g.decision?.kind, 'fullPlanOffer');
const original = player(g, 'e').hand[0].id;
const offered = applyAction(g, 'e', { type: 'card', mode: 'special', card: original, target: 'b' });
assert.equal(offered.battle?.fullPlan ?? null, null);
const prevented = finishKull(declare(offered));
assert.equal(prevented.decision?.kind, 'fullPlanOffer');
assert.equal(player(prevented, 'e').specialKaramaUsed, undefined);
assert.ok(held(prevented, 'e', original));
const declined = applyAction(reload(offered), 'c', { type: 'kullDecision', event: offered.pendingKull!.event, decline: true });
assert.deepEqual(declined.battle?.fullPlan, { owner: 'e', target: 'b' });
assert.equal(player(declined, 'e').specialKaramaUsed, true);
assert.equal(declined.discard.filter(card => card.id === original).length, 1);
});
void test('a canceled BG counter does not release the original card or prematurely apply the ban', () => {
const g = choamKullGame(), original = player(g, 'e').hand[0].id;
const window = declare(applyAction(g, 'e', kullShipmentAttempt(g)));
const bg = player(g, 'b').hand[0].id;
const conversion = applyAction(window, 'b', { type: 'card', mode: 'cancel', card: bg });
const result = applyAction(reload(conversion), 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
assert.equal(result.pendingKull, null);
assert.ok(held(result, 'e', original));
assert.equal(result.karamaShipping, null);
assert.equal(result.discard.filter(card => card.id === bg).length, 1);
assert.equal(result.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
assert.equal(viewGame(result, 'e').karamaBlocked !== null, true);
});
void test('nonopted games keep native Karama execution and no Kull opportunity', () => {
const g = choamKullGame();
delete g.kullPreview;
const original = player(g, 'e').hand[0].id;
const result = applyAction(g, 'e', kullShipmentAttempt(g));
assert.deepEqual(result.karamaShipping, { player: 'e', owner: 'e', card: original });
assert.equal(result.discard.filter(card => card.id === original).length, 1);
assert.equal(result.pendingKull, undefined);
assert.equal(viewGame(result, 'c').kullReaction, null);
assert.equal(viewGame(result, 'c').kullPreview, false);
});
void test('unproven live Truthtrance cancellation rejects privately before Kull cost or offer', () => {
let g = choamKullGame({ opponents: ['atreides', 'beneGesserit', 'harkonnen'] });
g.phase = 6;
g.active = 'e';
for (const p of g.players) { p.forces = {}; p.reserves = 20; p.advisors = {}; }
for (const id of ['e', 'b']) { player(g, id).forces = { 'arrakeen:10': 2 }; player(g, id).reserves = 18; }
g = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'b' });
assert.equal(g.battle?.preparation?.kind, 'voice');
g = finishConversion(g, 'kwisatz');
g = applyAction(g, 'b', { type: 'declineBattlePower' });
g = applyAction(g, 'e', { type: 'prescience', field: 'dial' });
const attemptedConversion = applyAction(g, 'b', { type: 'card', mode: 'cancel', card: player(g, 'b').hand[0].id });
g = applyAction(attemptedConversion, 'c', { type: 'kullDecision', event: attemptedConversion.pendingKull!.event, decline: true });
assert.equal(g.response?.kind, 'worthlessKarama');
// A valid, feasible saved answer; this response's cancellation suffix is
// intentionally outside the engine's pure promise-feasibility quote.
g.battle!.truthPromises = [{ player: 'e', asker: 'h', claim: { kind: 'dial', compare: 'eq', value: 0 }, answer: true }];
const absent = reload(g);
absent.deck.push(...player(absent, 'c').hand);
player(absent, 'c').hand = [];
for (const state of [g, absent]) {
const before = JSON.stringify(state);
assert.throws(() => applyAction(state, 'e', { type: 'card', mode: 'cancel', card: player(state, 'e').hand[0].id }), /unproven Truthtrance/);
assert.equal(JSON.stringify(state), before);
assert.equal(state.pendingKull, null);
assert.equal(state.battle?.truthPromises?.[0].released, undefined);
}
});
void test('a phase-blocked held Karama does not authorize accepting an unsupported unfunded overbid', () => {
const g = choamKullGame();
auction(g, 0, false);
const blocked = finishKull(declare(applyAction(g, 'e', { type: 'card', mode: 'purchase', card: player(g, 'e').hand[0].id })));
assert.ok(held(blocked, 'e', player(g, 'e').hand[0].id));
const before = JSON.stringify(blocked);
assert.throws(() => applyAction(blocked, 'e', { type: 'bid', amount: player(blocked, 'e').spice + 1 }), /defers/);
assert.equal(JSON.stringify(blocked), before);
const funded = applyAction(blocked, 'e', { type: 'bid', amount: 1 });
assert.equal(funded.auction?.bid, 1);
assert.equal(funded.auction?.bidder, 'e');
});
function preparedKullBattle() {
let g = choamKullGame();
Object.assign(g, { phase: 6, active: 'e' });
for (const p of g.players) { p.forces = {}; p.reserves = 20; p.advisors = {}; }
for (const id of ['e', 'b']) {
player(g, id).forces = { 'arrakeen:10': 2 };
player(g, id).reserves = 18;
}
g = applyAction(g, 'e', { type: 'chooseBattle', territory: 'arrakeen', target: 'b' });
if (g.response) g = finishConversion(g, g.response.kind);
return g;
}
void test('a promised battle card cannot be spent as an unproven Kull counter', () => {
const g = preparedKullBattle();
g.battle!.preparation = undefined;
g.battle!.truthPromises = [{
player: 'b', asker: 'h', claim: { kind: 'weapon', name: 'Baliset' }, answer: true,
}];
g.response = { kind: 'eliteStrength', owner: 'e', passed: [] };
const offered = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
const counter = declare(offered), before = JSON.stringify(counter);
const cost = player(counter, 'b').hand[0].id;
assert.deepEqual(viewGame(counter, 'b').responseControls?.cancelCards, []);
assert.throws(() => applyAction(counter, 'b', { type: 'card', mode: 'cancel', card: cost }));
assert.equal(JSON.stringify(counter), before);
assert.ok(held(counter, 'b', cost));
for (const profile of DIFFICULTIES) {
let automatic = reload(counter);
for (const p of automatic.players) p.bot = profile;
for (let step = 0; automatic.pendingKull && step < 12; step++) {
const choice = automatic.players.flatMap(p =>
botActions(viewGame(automatic, p.id)).map(action => ({ actor: p.id, action })))[0];
assert.ok(choice, 'a legal counter or automatic allowance must progress');
automatic = applyAction(automatic, choice.actor, choice.action);
}
assert.equal(automatic.pendingKull, null);
assert.ok(held(automatic, 'b', cost));
assert.equal(automatic.battle!.truthPromises![0].released, undefined);
}
assert.equal(counter.battle!.truthPromises![0].released, undefined);
});
void test('automatic Kull counter recovery preserves the suspended Voice promise', () => {
let g = preparedKullBattle();
player(g, 'e').hand.push(takeKullCard(g, 'Shield'));
g.battle!.truthPromises = [{
player: 'e', asker: 'h', claim: { kind: 'defense', name: 'Shield' }, answer: true,
}];
g = applyAction(g, 'b', { type: 'voice', kind: 'shield', must: false });
g = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: player(g, 'h').hand[0].id });
const counter = declare(g);
const restored = normalizeAutomaticGame(reload(counter));
assert.equal(restored.battle!.truthPromises![0].released, undefined);
assert.equal(restored.pendingKull?.event, counter.pendingKull?.event);
for (const id of ['c', 'e', 'b', 'h'])
assert.deepEqual(viewGame(restored, id), viewGame(counter, id));
});
void test('orphaned direct or converted Kull counters reject before views, recovery or new actions', () => {
const initial = choamKullGame();
const declared = declare(applyAction(initial, 'e', kullShipmentAttempt(initial)));
const converted = applyAction(declared, 'b', {
type: 'card', mode: 'cancel', card: player(declared, 'b').hand[0].id,
});
for (const source of [declared, converted])
for (const remove of [true, false]) {
const corrupt = reload(source);
if (remove) delete corrupt.pendingKull;
else corrupt.pendingKull = null;
const before = JSON.stringify(corrupt);
assert.throws(() => viewGame(corrupt, 'h'), /lost its interrupted Karama frame/);
assert.throws(() => normalizeAutomaticGame(corrupt), /lost its interrupted Karama frame/);
assert.throws(() => applyAction(corrupt, 'h', { type: 'passResponse' }),
/lost its interrupted Karama frame/);
assert.equal(JSON.stringify(corrupt), before);
}
});
void test('Kull counter preserves a genuine Auditor response until the original cancellation resumes', () => {
let g = choamKullGame();
Object.assign(g, { phase: 6, active: 'c' });
for (const p of g.players) {
p.forces = ['c', 'e'].includes(p.id) ? { 'arrakeen:10': 3 } : {};
p.reserves = ['c', 'e'].includes(p.id) ? 17 : 20;
p.traitors = [];
}
g = applyAction(g, 'c', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' });
for (let step = 0; step < 30; step++) {
if (g.response) g = finishConversion(g, g.response.kind);
else if (g.battle?.preLeader && !g.battle.preLeader.closed) {
const id = ['c', 'e'].find(id => !g.battle!.preLeader!.ready.includes(id))!;
g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle.event });
} else if (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
}
g = applyAction(g, 'c', { type: 'battlePlan', leader: 'choam-auditor', dial: 0, support: 0 });
g = applyAction(g, 'e', { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0 });
g = applyAction(g, 'c', { type: 'traitorCall', call: false });
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
if (g.response) g = finishConversion(g, g.response.kind);
assert.equal(g.decision?.kind, 'choamAudit');
g = applyAction(g, 'c', { type: 'decision', audit: true, event: g.pendingAuditor!.event });
assert.equal(g.response?.kind, 'choamAudit');
const original = player(g, 'h').hand[0].id;
const offered = applyAction(g, 'h', { type: 'card', mode: 'cancel', card: original });
const counter = normalizeAutomaticGame(reload(declare(offered)));
assert.equal(counter.pendingAuditor?.stage, 'response');
for (const id of ['c', 'e', 'b', 'h']) assert.ok(viewGame(counter, id).response);
const cost = player(counter, 'e').hand[0].id;
const completed = applyAction(counter, 'e', { type: 'card', mode: 'cancel', card: cost });
assert.equal(completed.pendingKull, null);
assert.equal(completed.pendingAuditor, null);
assert.ok(held(completed, 'c', 'ix-kull-wahad'));
assert.equal(completed.discard.filter(card => card.id === original).length, 1);
assert.equal(completed.discard.filter(card => card.id === cost).length, 1);
});
void test('CHOAM cannot voluntarily spend the Kull card required by its own battle promise', () => {
let g = choamKullGame();
Object.assign(g, { phase: 6, active: 'c' });
for (const p of g.players) { p.forces = {}; p.reserves = 20; p.advisors = {}; }
for (const id of ['c', 'e']) {
player(g, id).forces = { 'arrakeen:10': 3 };
player(g, id).reserves = 17;
}
g = applyAction(g, 'c', { type: 'chooseBattle', territory: 'arrakeen', target: 'e' });
if (g.response) g = finishConversion(g, g.response.kind);
g.battle!.preparation = undefined;
g.battle!.truthPromises = [{
player: 'c', asker: 'h', claim: { kind: 'weapon', name: 'Kull Wahad' }, answer: true,
}];
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.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',
}), /eligible held Kull/);
assert.equal(JSON.stringify(offered), before);
const declined = applyAction(offered, 'c', {
type: 'kullDecision', event: offered.pendingKull!.event, decline: true,
});
assert.ok(held(declined, 'c', 'ix-kull-wahad'));
assert.equal(declined.battle!.truthPromises![0].released, undefined);
});
void test('deferred unfunded bids reject before commitment regardless of hidden Kull, without changing non-preview bidding', () => {
const g = choamKullGame();
player(g, 'e').spice = 1;
auction(g, 0, false);
const absent = reload(g);
const replacement = absent.deck.shift()!;
absent.deck.push(...player(absent, 'c').hand);
player(absent, 'c').hand = [replacement];
for (const state of [g, absent]) {
const before = JSON.stringify(state);
assert.throws(() => applyAction(state, 'e', { type: 'bid', amount: 2 }));
assert.equal(JSON.stringify(state), before);
assert.equal(state.auction?.bid, 0);
assert.equal(state.auction?.bidder, null);
}
const normal = reload(g);
delete normal.kullPreview;
const bid = applyAction(normal, 'e', { type: 'bid', amount: 2 });
assert.equal(bid.auction?.bid, 2);
assert.equal(bid.auction?.bidder, 'e');
});
void test('CHOAM self-activation remains outside Kull interception and can settle its funded-by-Karama bid', () => {
let g = choamKullGame();
const card = takeKullCard(g, player(g, 'h').hand[0].id);
player(g, 'c').hand.push(card);
player(g, 'c').spice = 1;
auction(g, 0, false);
g.active = 'c';
g.auction!.active = 'c';
g = applyAction(g, 'c', { type: 'bid', amount: 2 });
for (let step = 0; g.auction && !g.decision && step < 12; step++)
g = applyAction(g, g.auction.active!, { type: 'passBid' });
assert.equal(g.decision?.kind, 'auctionPayment');
const done = applyAction(g, 'c', { type: 'decision', karama: true, card: card.id });
assert.equal(done.pendingKull ?? null, null);
assert.ok(held(done, 'c', 'ix-kull-wahad'));
assert.equal(done.discard.filter(used => used.id === card.id).length, 1);
});
+406
View File
@@ -0,0 +1,406 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import type * as Rooms from '../db/rooms';
import { viewGame, type Action, type Game, type GameView } from '../game/engine';
import { unitStore } from './fixture-nexus-room-store';
import { choamKullGame, kullShipmentAttempt, takeKullCard } from './fixture-choam-kull';
const clock = { now: () => 10000, sleep: async () => {} };
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
const credentials = (sqlite: DatabaseSync) => ({
seats: sqlite.prepare('SELECT * FROM seats ORDER BY token_hash').all(),
entries: sqlite.prepare('SELECT * FROM room_entry_receipts ORDER BY 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(),
});
function inventory(g: Game) {
return [...g.deck, ...g.discard, ...(g.richeseCache ?? []), ...g.players.flatMap(p => p.hand)]
.map(card => card.id).sort();
}
async function persisted(prepare?: (g: Game) => void) {
const store = unitStore();
try {
const created = await store.rooms.createRoom('Kull SQLite QA', 'choam', true, ['choam', 'ix']);
const code = created.view.code;
const joined = [
await store.rooms.joinRoom(code, 'Emperor', 'emperor'),
await store.rooms.joinRoom(code, 'Bene Gesserit', 'beneGesserit'),
await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen'),
];
const tokens = [created.token, ...joined.map(seat => seat.token!)];
const auths = await Promise.all(tokens.map(token => store.restart().authenticate(code, token)));
const current = await store.rooms.readRoom(code);
const initial = choamKullGame({ seatIds: auths.map(auth => auth.playerId) });
initial.code = code;
initial.host = auths[0].playerId;
initial.version = current.version;
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);
store.writes.length = 0;
return { ...store, code, tokens, auths, initial,
originalCredentials: credentials(store.sqlite), originalCards: inventory(initial) };
} catch (error) {
store.sqlite.close();
throw error;
}
}
type Fixture = {
rooms: typeof Rooms;
restart: () => typeof Rooms;
sqlite: DatabaseSync;
hooks: { beforeWrite?: () => Promise<void>; beforeStatement?: (sql: string) => Promise<void> };
writes: { expected: number; changes: number }[];
code: string;
tokens: string[];
auths: Rooms.SeatAuth[];
initial: Game;
originalCredentials: { seats: unknown[]; entries: unknown[]; keys: unknown[]; recoveries: unknown[] };
originalCards: string[];
};
async function act(f: Fixture, g: Game, actor: string, action: Action) {
const rooms = f.restart();
const auth = f.auths.find(auth => auth.playerId === actor)!;
await rooms.act(f.code, auth, g.version, action, clock);
return rooms.readRoom(f.code);
}
async function restored(f: Fixture, g: Game) {
const rooms = f.restart();
const views: GameView[] = [];
for (let index = 0; index < f.tokens.length; index++) {
const auth = await rooms.authenticate(f.code, f.tokens[index]);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(g, auth.playerId));
if (g.pendingKull) {
assert.equal('pendingKull' in view, false, 'saved intent and suspended controls are not a projection');
assert.equal(JSON.stringify(view).includes(JSON.stringify(g.pendingKull.signature)), false);
}
assert.deepEqual(view.players.find(p => p.id === auth.playerId)!.hand, player(g, auth.playerId).hand);
for (const other of view.players.filter(p => p.id !== auth.playerId)) {
assert.equal(other.hand, undefined);
assert.equal(other.spice, undefined);
}
views.push(view);
}
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
assert.deepEqual(inventory(g), f.originalCards);
assert.equal(new Set(inventory(g)).size, f.originalCards.length);
return views;
}
async function rejected(f: Fixture, g: Game, actor: string, action: Action, version = g.version) {
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const attemptedStatements: string[] = [];
f.hooks.beforeStatement = async sql => { attemptedStatements.push(sql); };
try {
await assert.rejects(f.restart().act(f.code, f.auths.find(auth => auth.playerId === actor)!,
version, action, clock));
} finally { delete f.hooks.beforeStatement; }
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);
}
async function stationary(f: Fixture, g: Game) {
const before = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(f.code);
const writes = f.writes.length;
await 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, writes);
await restored(f, g);
}
async function passes(f: Fixture, initial: Game) {
let g = initial;
for (let step = 0; g.response && step < 16; step++) {
const responder = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(responder, 'an unresolved response must retain a real seat');
g = await act(f, g, responder.id, { type: 'passResponse' });
await restored(f, g);
}
assert.equal(g.response, null);
return g;
}
async function compete(f: Fixture, g: Game, actor: string, action: Action) {
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const priorWrites = f.writes.length;
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await gate; };
const auth = f.auths.find(auth => auth.playerId === actor)!;
try {
const outcomes = await Promise.allSettled([
f.rooms.act(f.code, auth, g.version, action, clock),
f.restart().act(f.code, auth, g.version, action, clock),
]);
assert.equal(arrivals, 2);
assert.equal(outcomes.filter(outcome => outcome.status === 'fulfilled').length, 1);
assert.equal(outcomes.filter(outcome => outcome.status === 'rejected').length, 1);
assert.deepEqual(f.writes.slice(priorWrites).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
} finally { delete f.hooks.beforeWrite; }
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, g.version + 1);
await restored(f, done);
return done;
}
async function offer(f: Fixture, initial: Game, actor: string, action: Action, expectedCards = ['ix-kull-wahad']) {
const g = await act(f, initial, actor, action);
const views = await restored(f, g);
const reaction = views[0].kullReaction;
assert.ok(reaction);
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);
const publicFields = { event: reaction.event, player: reaction.player, target: reaction.target,
intent: reaction.intent, cards: [], 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);
await stationary(f, g);
return { g, event: reaction.event };
}
void test('persisted Kull offer and racing decline revive Emperor forces exactly once after restart', async () => {
const f = await persisted(g => {
g.phase = 4;
g.active = null;
player(g, g.players[1].id).tanks = 4;
player(g, g.players[1].id).reserves -= 4;
});
try {
const emperor = f.auths[1].playerId;
const card = player(f.initial, emperor).hand.find(card => card.effect === 'karama')!;
const original = { type: 'card', mode: 'special', card: card.id, amount: 2 };
const { g, event } = await offer(f, f.initial, emperor, original);
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, 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);
assert.equal(player(done, emperor).tanks, 2);
assert.equal(player(done, emperor).reserves, player(f.initial, emperor).reserves + 2);
assert.equal(player(done, emperor).specialKaramaUsed, true);
assert.equal(done.discard.filter(held => held.id === card.id).length, 1);
assert.equal(player(done, f.auths[0].playerId).hand.filter(held => held.id === 'ix-kull-wahad').length, 1);
assert.equal(viewGame(done, emperor).kullReaction, null);
await rejected(f, done, f.auths[0].playerId, decline);
await rejected(f, done, emperor, original);
await stationary(f, done);
} finally { f.sqlite.close(); }
});
void test('persisted Kull success races the final pass, retains printed Karama and blocks its phase activation', async () => {
const f = await persisted();
try {
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' });
assert.equal(g.response?.kind, 'choamWorthless');
assert.equal(g.karamaShipping, null);
assert.equal(g.discard.some(card => card.id === original.card), false);
await restored(f, g);
await stationary(f, g);
for (let step = 0; step < 4; step++) {
const eligible = g.players.filter(p => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
});
assert.ok(eligible.length > 0, 'the final-pass race must reach an unresolved legal counter seat');
if (eligible.length === 1) break;
g = await act(f, g, eligible[0].id, { type: 'passResponse' });
}
const finalSeat = g.players.find(p => {
const controls = viewGame(g, p.id).responseControls;
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
})!;
const done = await compete(f, g, finalSeat.id, { type: 'passResponse' });
assert.equal(done.response, null);
assert.equal(viewGame(done, emperor).kullReaction, null);
assert.equal(done.karamaShipping, null);
assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
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 stationary(f, done);
} finally { f.sqlite.close(); }
});
void test('a distinct held Karama counters persisted Kull and resumes the original shipment only once', async () => {
const f = await persisted(g => {
const distinct = player(g, g.players[3].id).hand.find(card => card.effect === 'karama')!;
player(g, g.players[1].id).hand.push(takeKullCard(g, distinct.id));
});
try {
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
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 views = await restored(f, g);
assert.deepEqual(views[1].responseControls?.cancelCards, [distinct.id]);
assert.equal(views[1].responseControls?.cancelCards.includes(String(original.card)), false);
await rejected(f, g, emperor, { type: 'card', mode: 'cancel', card: original.card });
const counter = { type: 'card', mode: 'cancel', card: distinct.id };
const done = await compete(f, g, emperor, counter);
assert.deepEqual(done.karamaShipping, { player: emperor, owner: emperor, card: original.card });
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 === distinct.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);
assert.equal(viewGame(done, emperor).kullReaction, null);
await rejected(f, done, emperor, counter);
await rejected(f, done, choam, { type: 'kullDecision', event, decline: true });
await stationary(f, done);
} finally { f.sqlite.close(); }
});
void test('Kull intercepts persisted BG substitution before conversion and retains every attempted physical card', async () => {
const f = await persisted(g => {
const extra = g.deck.find(card => card.kind === 'worthless')!;
player(g, g.players[2].id).hand.push(takeKullCard(g, extra.id));
});
try {
const bg = f.auths[2].playerId, emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
const worthless = player(f.initial, bg).hand.filter(card => card.kind === 'worthless');
assert.equal(worthless.length, 2);
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' });
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);
await restored(f, g);
await stationary(f, g);
g = await passes(f, g);
assert.equal(g.karamaShipping, null);
assert.deepEqual(player(g, bg).hand, player(f.initial, bg).hand);
assert.equal(g.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
assert.equal(g.discard.some(card => card.id === worthless[0].id), false);
await rejected(f, g, bg, original);
await rejected(f, g, bg, { ...original, card: worthless[1].id });
await stationary(f, g);
} finally { f.sqlite.close(); }
});
void test('declining persisted Kull resumes the real BG conversion and shipment without repeated disposal', async () => {
const f = await persisted();
try {
const bg = f.auths[2].playerId, emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
const worthless = player(f.initial, bg).hand.find(card => card.kind === 'worthless')!;
const original = { type: 'card', mode: 'shipment', card: worthless.id, target: emperor };
const { g: offered, event } = await offer(f, f.initial, bg, original);
const decline = { type: 'kullDecision', event, decline: true };
let g = await act(f, offered, choam, decline);
assert.equal(g.response?.kind, 'worthlessKarama');
assert.ok(g.pendingKarama);
assert.equal(g.karamaShipping, null);
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(player(g, bg).hand.some(card => card.id === worthless.id), false);
await restored(f, g);
await stationary(f, g);
g = await passes(f, g);
assert.deepEqual(g.karamaShipping, { player: emperor, owner: bg, card: worthless.id });
assert.equal(g.pendingKarama, null);
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
assert.equal(player(g, choam).hand.filter(card => card.id === 'ix-kull-wahad').length, 1);
await rejected(f, g, choam, decline);
await rejected(f, g, bg, original);
await stationary(f, g);
} finally { f.sqlite.close(); }
});
void test('successful persisted Kull prevents Emperor revival before forces or its special once-use are spent', async () => {
const f = await persisted(g => {
g.phase = 4;
g.active = null;
player(g, g.players[1].id).tanks = 4;
player(g, g.players[1].id).reserves -= 4;
});
try {
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
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' });
await restored(f, declared);
const done = await passes(f, declared);
assert.deepEqual(player(done, emperor), player(f.initial, emperor));
assert.equal(player(done, emperor).specialKaramaUsed ?? false, false);
assert.equal(done.discard.filter(card => card.id === 'ix-kull-wahad').length, 1);
assert.equal(done.discard.some(card => card.id === karama.id), false);
await rejected(f, done, emperor, original);
await stationary(f, done);
} finally { f.sqlite.close(); }
});
void test('a Kull-free saved CHOAM hand still receives the neutral private offer and can decline the real attempt', async () => {
const f = await persisted(g => { g.deck.push(takeKullCard(g, 'ix-kull-wahad')); });
try {
const emperor = f.auths[1].playerId, choam = f.auths[0].playerId;
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' });
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);
assert.equal(done.discard.some(card => card.id === 'ix-kull-wahad'), false);
await stationary(f, done);
} finally { f.sqlite.close(); }
});
void test('corrupt saved Kull intent ownership cannot execute or write after a room-module restart', async () => {
const f = await persisted();
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));
assert.ok(g.pendingKull);
const corrupt = structuredClone(g);
corrupt.pendingKull!.owner = f.auths[2].playerId;
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 assert.rejects(f.restart().readSeatView(f.code, f.auths[0]));
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
} finally { f.sqlite.close(); }
});
void test('orphaned saved Kull counters cannot be projected or commit a partial pass', async () => {
for (const conversion of [false, true]) {
const f = await persisted();
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' });
if (conversion) {
const bg = f.auths[2].playerId;
counter = await act(f, counter, bg, {
type: 'card', mode: 'cancel', card: player(counter, bg).hand[0].id,
});
}
const corrupt = structuredClone(counter);
delete corrupt.pendingKull;
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=? AND version=?')
.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/);
assert.deepEqual(credentials(f.sqlite), f.originalCredentials);
} finally { f.sqlite.close(); }
}
});
+203
View File
@@ -0,0 +1,203 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { baseDeck, ixStandardCards, type Card } from '../game/cards';
import { canUseAsKarama } from '../game/karama';
import { canUseAsKaramaRole } from '../game/shrine';
import {
activeKullRestrictions,
distinctKullCounter,
kullBlocksKarama,
kullCounterCards,
kullNativeCostCards,
validateKullAttemptStamp,
validateKullPhaseRestrictions,
type KullAttemptStamp,
type KullContext,
type KullPhaseRestriction,
} from '../game/choam-kull';
const context: KullContext = {
turn: 2,
phase: 5,
players: [
{ id: 'choam', faction: 'choam' },
{ id: 'actor', faction: 'beneGesserit' },
{ id: 'third', faction: 'atreides' },
],
};
void test('a Kull restriction expires by both turn and phase and follows actor, not beneficiary', () => {
const restriction = Object.freeze({ player: 'actor', turn: 2, phase: 5 });
const history = Object.freeze([
restriction,
Object.freeze({ player: 'third', turn: 1, phase: 5 }),
]);
validateKullPhaseRestrictions(context, history);
assert.equal(kullBlocksKarama(history, 2, 5, 'actor'), true);
assert.equal(kullBlocksKarama(history, 2, 5, 'third'), false);
assert.equal(kullBlocksKarama(history, 2, 5, 'choam'), false);
for (const [turn, phase] of [[2, 6], [3, 5], [3, 0]]) {
assert.equal(kullBlocksKarama(history, turn, phase, 'actor'), false);
assert.deepEqual(activeKullRestrictions(history, turn, phase), []);
}
assert.deepEqual(activeKullRestrictions(history, 2, 5), [restriction]);
assert.notEqual(activeKullRestrictions(history, 2, 5)[0], restriction);
assert.equal(kullBlocksKarama(undefined, 2, 5, 'actor'), false);
});
void test('malformed and future restriction history is rejected without repair or input mutation', () => {
const valid = { player: 'actor', turn: 2, phase: 5 };
const malformed: unknown[] = [
null,
{ ...valid, turn: 0 },
{ ...valid, turn: 1.5 },
{ ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, turn: 3 },
{ ...valid, phase: -1 },
{ ...valid, phase: 9 },
{ ...valid, phase: 5.5 },
{ ...valid, phase: 6 },
{ ...valid, player: 'foreign' },
{ ...valid, player: '' },
{ ...valid, extra: true },
];
for (const record of malformed) {
const history = [record];
const before = structuredClone(history);
assert.throws(() => validateKullPhaseRestrictions(context, history));
assert.deepEqual(history, before);
}
const duplicate = [valid, { ...valid }];
const before = structuredClone(duplicate);
assert.throws(() => validateKullPhaseRestrictions(context, duplicate));
assert.deepEqual(duplicate, before);
assert.throws(() => validateKullPhaseRestrictions(context, valid));
assert.throws(() => validateKullPhaseRestrictions({ ...context, turn: 0 }, []));
assert.equal(kullBlocksKarama([{ ...valid, phase: 9 }], 2, 9, 'actor'), false);
});
void test('valid expired restrictions stay inert without erasing another player current restriction', () => {
const history: KullPhaseRestriction[] = [
{ player: 'actor', turn: 1, phase: 8 },
{ player: 'actor', turn: 2, phase: 4 },
{ player: 'third', turn: 2, phase: 5 },
];
const before = structuredClone(history);
validateKullPhaseRestrictions(context, history);
assert.equal(kullBlocksKarama(history, 2, 5, 'actor'), false);
assert.equal(kullBlocksKarama(history, 2, 5, 'third'), true);
assert.deepEqual(activeKullRestrictions(history, 2, 5), [history[2]]);
assert.deepEqual(history, before);
});
void test('saved attempts require the current stamp, original seated actors and unique physical identity', () => {
const physical = baseDeck();
const card = physical.find(candidate => candidate.effect === 'karama')!;
const attempt: KullAttemptStamp = {
event: 'attempt-1', player: 'choam', owner: 'actor', card: card.id,
turn: 2, phase: 5, stage: 'offer', form: 'printed',
};
const patches: Partial<KullAttemptStamp>[] = [
{ event: '' }, { player: 'third' }, { player: 'foreign' },
{ owner: 'choam' }, { owner: 'foreign' }, { card: 'invented' },
{ turn: 1 }, { turn: 3 }, { turn: 0 }, { phase: 4 }, { phase: 6 },
{ phase: 9 }, { stage: 'complete' as never }, { form: 'converted' as never },
];
for (const patch of patches) {
const invalid = { ...attempt, ...patch };
const before = structuredClone({ invalid, physical, context });
assert.throws(() => validateKullAttemptStamp(context, invalid, physical));
assert.deepEqual({ invalid, physical, context }, before);
}
for (const inventory of [physical.filter(candidate => candidate.id !== card.id), [...physical, card]]) {
const before = structuredClone(inventory);
assert.throws(() => validateKullAttemptStamp(context, attempt, inventory));
assert.deepEqual(inventory, before);
}
// Validate both live stages after serialization; the exact original stays reserved.
for (const stage of ['offer', 'counter'] as const) {
const saved = JSON.parse(JSON.stringify({ ...attempt, stage })) as KullAttemptStamp;
validateKullAttemptStamp(context, saved, physical);
assert.equal(distinctKullCounter(saved.card, attempt.card), false);
assert.equal(distinctKullCounter(physical.find(candidate =>
candidate.effect === 'karama' && candidate.id !== card.id)!.id, saved.card), true);
}
const substitution = physical.find(candidate => candidate.kind === 'worthless')!;
const bgSaved = { ...attempt, card: substitution.id, form: 'substitution' as const };
validateKullAttemptStamp(context, bgSaved, physical);
assert.equal(distinctKullCounter(substitution.id, bgSaved.card), false);
});
void test('native Kull costs require canonical identity and unique actual custody, not a matching label', () => {
const physical = [...baseDeck(), ...ixStandardCards()];
const kull = physical.find(card => card.id === 'ix-kull-wahad')!;
const another = physical.find(card => card.kind === 'worthless' && card.id !== kull.id)!;
const held = Object.freeze([Object.freeze(kull), Object.freeze(another)]);
const before = structuredClone({ held, physical });
assert.deepEqual(kullNativeCostCards(held, physical).map(card => card.id), [kull.id]);
assert.deepEqual(kullNativeCostCards([another], physical), []);
assert.deepEqual(kullNativeCostCards(held, physical, [kull.id]), []);
assert.deepEqual(kullNativeCostCards(held, physical.filter(card => card.id !== kull.id)), []);
assert.deepEqual(kullNativeCostCards(held, [...physical, { ...kull }]), []);
assert.deepEqual(kullNativeCostCards([kull, { ...kull }], physical), []);
for (const fake of [
{ ...another, name: kull.name },
{ ...kull, kind: 'special' as const },
{ ...kull, name: 'different' },
{ ...kull, effect: 'karama' },
]) {
assert.deepEqual(kullNativeCostCards([fake], physical), []);
}
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');
const worthless = physical.find(card => card.kind === 'worthless')!;
const truthtrance = physical.find(card => card.effect === 'truthtrance')!;
const held = [karamas[0], karamas[1], worthless, truthtrance];
const before = structuredClone({ held, physical });
assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
card => canUseAsKarama(false, 'atreides', card)).map(card => card.id), [karamas[1].id]);
assert.deepEqual(kullCounterCards(held, physical, worthless.id,
card => canUseAsKarama(true, 'beneGesserit', card)).map(card => card.id),
[karamas[0].id, karamas[1].id]);
assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
card => canUseAsKarama(true, 'beneGesserit', card)).map(card => card.id),
[karamas[1].id, worthless.id]);
const shrine = {
advanced: true,
discoveries: { tokens: [{ face: 'shrine', status: 'placed', revealedTurn: 2 }] },
};
const actor = { id: 'third', faction: 'atreides' as const, forces: { 'shrine:0': 1 } };
assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
card => canUseAsKaramaRole(shrine, actor, card)).map(card => card.id),
[karamas[1].id, truthtrance.id]);
assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
card => canUseAsKaramaRole(shrine, { ...actor, forces: {} }, card)).map(card => card.id),
[karamas[1].id]);
const block = [{ player: actor.id, turn: 2, phase: 5 }];
assert.deepEqual(kullCounterCards(held, physical, karamas[0].id,
card => !kullBlocksKarama(block, 2, 5, actor.id) && canUseAsKaramaRole(shrine, actor, card)), []);
assert.deepEqual({ held, physical }, before);
});
void test('counter rejection checks exact physical custody before invoking effective eligibility', () => {
const physical = baseDeck();
const original = physical.find(card => card.effect === 'karama')!;
const distinct = physical.find(card => card.effect === 'karama' && card.id !== original.id)!;
const forged: Card = { ...distinct, kind: 'worthless', effect: undefined };
let eligibilityCalls = 0;
const eligible = (card: Card) => { eligibilityCalls++; return card.effect === 'karama'; };
assert.deepEqual(kullCounterCards([original], physical, original.id, eligible), []);
assert.deepEqual(kullCounterCards([forged], physical, original.id, eligible), []);
assert.deepEqual(kullCounterCards([distinct, distinct], physical, original.id, eligible), []);
assert.deepEqual(kullCounterCards([distinct], [...physical, distinct], original.id, eligible), []);
assert.deepEqual(kullCounterCards([distinct], physical, original.id, eligible, [distinct.id]), []);
assert.equal(eligibilityCalls, 0);
assert.deepEqual(kullCounterCards([distinct], physical, original.id, eligible).map(card => card.id), [distinct.id]);
assert.equal(eligibilityCalls, 1);
assert.equal(distinctKullCounter('', original.id), false);
assert.equal(distinctKullCounter(distinct.id, ''), false);
});
@@ -508,3 +508,35 @@ void test('pure unrelated responses stay with their own cancellation validators'
null,
);
});
void test('denied Kull phase-blocks the exact declared effect without consuming another phase context', () => {
for (let phase = 0; phase <= 8; phase++) {
const g = createGame('KULLDENY', newPlayer('c', 'CHOAM', 'choam'), true, ['choam', 'ix']);
g.players.push(newPlayer('a', 'Atreides', 'atreides'));
Object.assign(g, { status: 'playing', turn: 2, phase });
g.pendingChoamWorthless = {
owner: 'c', card: 'ix-kull-wahad', effect: 'kull', target: 'a', revival: false,
};
const response = {
kind: 'choamWorthless' as const, owner: 'c', recipient: 'a',
intent: 'Kull Wahad', passed: [],
};
const before = JSON.stringify(g);
const quote = quoteChoamWorthlessCancellation(g, response)!;
assert.deepEqual(quote.blocked, { turn: 2, phase, cards: ['ix-kull-wahad'] });
assert.deepEqual(quote.resume, { kind: 'none' });
assert.equal(quote.pendingChoamWorthless, null);
assert.equal(JSON.stringify(g), before);
for (const fields of [
{ target: 'c' }, { target: 'foreign' }, { location: 'arrakeen' },
{ elite: 0 }, { movement: true }, { noFieldEvent: 'foreign' },
]) {
const corrupt = reload(g);
Object.assign(corrupt.pendingChoamWorthless!, fields);
const snapshot = JSON.stringify(corrupt);
assert.throws(() => quoteChoamWorthlessCancellation(corrupt, response),
ChoamWorthlessCancellationError);
assert.equal(JSON.stringify(corrupt), snapshot);
}
}
});
+55
View File
@@ -0,0 +1,55 @@
import { applyAction, createGame, initializeKullGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
export function takeKullCard(g: Game, idOrEffect: string): Card {
const zones = [g.deck, g.discard, ...(g.richeseCache ? [g.richeseCache] : []), ...g.players.map(p => p.hand)];
for (const zone of zones) {
const index = zone.findIndex(card => card.id === idOrEffect || card.effect === idOrEffect || card.name === idOrEffect);
if (index >= 0) return zone.splice(index, 1)[0];
}
throw new Error(`Missing physical Kull fixture card: ${idOrEffect}`);
}
export function choamKullGame(options: {
seatIds?: readonly string[];
advanced?: boolean;
opponents?: readonly [FactionId, FactionId, FactionId];
} = {}): Game {
const ids = options.seatIds ?? ['c', 'e', 'b', 'h'];
if (ids.length !== 4 || new Set(ids).size !== 4) throw new Error('Kull fixture needs four distinct seat IDs.');
let g = createGame('KULLQA01', newPlayer(ids[0], 'CHOAM', 'choam'), options.advanced ?? true, ['choam', 'ix']);
const opponents: readonly FactionId[] = options.opponents ?? ['emperor', 'beneGesserit', 'harkonnen'];
for (let index = 0; index < 3; index++)
joinGame(g, newPlayer(ids[index + 1], opponents[index], opponents[index]));
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
g = initializeKullGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 100; step++) {
let next: Game | undefined;
for (const p of g.players) {
const action = botActions(viewGame(g, p.id))[0];
if (action) { next = applyAction(g, p.id, action); break; }
}
if (!next) throw new Error('Genuine Kull setup has no legal action.');
g = next;
}
if (g.status !== 'playing') throw new Error('Genuine Kull setup did not complete.');
g.deck.push(...g.players.flatMap(p => p.hand));
for (const p of g.players) { p.hand = []; delete p.bot; }
g.players[0].hand.push(takeKullCard(g, 'ix-kull-wahad'));
g.players[1].hand.push(takeKullCard(g, 'karama'));
g.players[2].hand.push(takeKullCard(g, 'Baliset'));
g.players[3].hand.push(takeKullCard(g, 'karama'));
Object.assign(g, { turn: 2, phase: 5, active: ids[1], storm: 18, ready: [], decision: null,
response: null, pendingKarama: null, phaseOpening: null, stormPending: null,
stormResolution: null, movementRemaining: [...ids] });
return g;
}
export function kullShipmentAttempt(g: Game, owner = g.players[1].id): Action {
const p = g.players.find(p => p.id === owner)!;
const card = p.hand.find(card => card.effect === 'karama' || (p.faction === 'beneGesserit' && card.kind === 'worthless'));
if (!card) throw new Error('Kull shipment fixture needs a held Karama.');
return { type: 'card', mode: 'shipment', card: card.id, target: owner };
}
-2
View File
@@ -85,7 +85,6 @@ void test('CHOAM cost selection keeps printed powers unchanged and exposes expli
for (const play of blocked.choamWorthless!.plays)
if (play.effect === 'kulon')
play.blocked = 'This card is reserved for another committed action.';
assert.match(html(blocked, 'kulon'), /reserved for another committed action/);
assert.match(html(blocked, 'kulon'), /disabled=""[^>]*>Use Kulon/);
for (const play of choamPowerPlays(blocked, 'kulon'))
assert.equal(choamPowerAction(blocked, { ...play, blocked: null }), null);
@@ -94,7 +93,6 @@ void test('CHOAM cost selection keeps printed powers unchanged and exposes expli
)!;
assert.ok(kull.blocked);
assert.equal(choamPowerAction(view, kull), null);
assert.match(html(view, 'kulon'), /Kull Wahad.*not implemented/);
});
void test('CHOAM shared actions fence stale source events, actors and interruption windows without reading outsider private choices', () => {
+22
View File
@@ -256,3 +256,25 @@ void test('Semuta development profile begins only a fresh Richese lobby without
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
});
void test('Kull preview starts a fresh ready CHOAM lobby once and preserves other rooms and seats', (t) => {
const { db } = fixture(t);
const game = createGame('PROTOTYP', newPlayer('c', 'CHOAM', 'choam'), false, ['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');
const result = startPrototypeRoom(db, game.code, 7, 'kull');
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
assert.equal(result.status, 'setup');
assert.equal(saved.kullPreview, true);
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, 'kull'));
assert.throws(() => startPrototypeRoom(db, game.code, 8, '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);
});
+16
View File
@@ -9,6 +9,7 @@ import {
type Game,
} from '../game/engine';
import { tableActionOwner } from '../game/table-turn';
import { choamKullGame, kullShipmentAttempt } from './fixture-choam-kull';
function battle(advanced = false) {
let g = createGame(
@@ -204,3 +205,18 @@ void test('full-plan prescience names its publicly selected target before either
assert.deepEqual(g.battle!.fullPlan, { owner: 'a', target: 'b' });
assert.deepEqual(owners(g), ['b', 'b', 'b', 'b']);
});
void test('real Kull offer gives CHOAM public decision ownership before the suspended actor', () => {
const initial = choamKullGame();
const choam = initial.players[0].id, actor = initial.players[1].id;
const offered = applyAction(initial, actor, kullShipmentAttempt(initial, actor));
assert.ok(offered.pendingKull);
assert.deepEqual(owners(offered), offered.players.map(() => choam));
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',
});
assert.equal(counter.response?.kind, 'choamWorthless');
assert.deepEqual(owners(counter), counter.players.map(() => null));
});