Adopt the supplied Advanced specific-effect wording, preserving Basic and legacy phase-wide semantics. Bind signed semantic purposes through ordinary/special activations, counters, feasibility and settlement; retain unrelated human controls and minimal bot actions. Phase expiry remains provisional; prior counter, BG and deferred-overbid choices stay. Validation: frozen12-file111/111, types/lint/build and two original-action/rendered-table programmes. Explicit unopened deal/auction/spice order supports real paid Baliset and shipments; Advanced retains Karama for Baliset cancellation while Basic stays blocked. Four minimal policies remain legal. Correct stale exchange/phase-market/Fremen fixture assumptions. Recovery schema expectations maintained without running deferred SQL assurance. No full modes, browser/deployed acceptance, saved-game/reset/restart or deployment operation.
470 lines
28 KiB
TypeScript
470 lines
28 KiB
TypeScript
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, passNexusKullBorrowedVoice } 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', actorFaction: 'atreides' });
|
|
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 });
|
|
game = passNexusKullBorrowedVoice(game);
|
|
game.battle!.truthPromises = [{ player: f.choam, asker: f.other!, claim: { kind: 'weapon', name: 'Hunter Seeker' }, answer: true }];
|
|
game = applyAction(game, f.actor, { type: 'prescience', field: 'dial' });
|
|
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('a declined Kull resumes the original Fremen special into its native Cunning offer', () => {
|
|
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.ok(summoned.nexusFremenCunningOffer);
|
|
assert.equal(summoned.nexusFremenCunningOffer.occurrence.parent, summoned.summonedWorm?.event);
|
|
assert.equal(summoned.decision?.kind, 'nexusFremenCunningOffer');
|
|
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);
|
|
const declined = applyAction(summoned, f.actor, {type:'decision',
|
|
event:summoned.nexusFremenCunningOffer.occurrence.event, accept:false});
|
|
assert.equal(declined.nexusFremenCunningOffer ?? null, null);
|
|
assert.equal(declined.nexusCards!.cards!.hands[f.actor], 'fremen');
|
|
assert.equal(declined.discard.filter(card => card.id === f.original).length, 1);
|
|
});
|
|
|
|
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);
|
|
});
|