Integrate CHOAM Nexus Cunning Kull with source-aware costs
This commit is contained in:
1 parent
fc5cbcd720
commit
0d9d0a72d6
32 files changed
+2502
-193
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user