Files
Dune/tests/bg-karama-cancel-context.test.ts
T
2026-09-08 07:37:52 +10:00

537 lines
18 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { baseDeck, spiceDeck } from '../game/cards';
import { location, territory } from '../game/board';
import { richeseCards } from '../game/richese-cards';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture() {
const g = createGame(
'BGCANCELCTX',
newPlayer('b', 'BG', 'beneGesserit'),
true,
['choam'],
);
g.players.push(
newPlayer('r', 'Richese', 'richese'),
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 2,
storm: 18,
active: 'b',
order: ['b', 'r', 'e', 'h'],
deck: baseDeck(),
richeseCache: richeseCards(),
phaseOpening: null,
});
for (const p of g.players) {
p.hand = [];
p.spice = 20;
p.forces = {};
p.reserves = 20;
p.traitors = [];
}
player(g, 'b').ally = 'r';
player(g, 'r').ally = 'b';
player(g, 'e').ally = 'h';
player(g, 'h').ally = 'e';
function hold(
id: string,
predicate: (c: Game['deck'][number]) => boolean,
cache = false,
) {
const pile = cache ? g.richeseCache! : g.deck,
at = pile.findIndex(predicate);
assert.ok(at >= 0);
const card = pile.splice(at, 1)[0];
player(g, id).hand.push(card);
return card.id;
}
const worthless = hold('b', (c) => c.kind === 'worthless'),
truth = hold('b', (c) => c.effect === 'truthtrance');
hold('b', (c) => c.name === 'Shield');
const printed = hold('e', (c) => c.effect === 'karama'),
special = hold('h', (c) => c.effect === 'karama');
hold('h', (c) => c.name === 'Maula Pistol');
const box = hold('r', (c) => c.effect === 'nullentropyBox', true),
distrans = hold('r', (c) => c.effect === 'distrans', true),
gift = hold('r', (c) => c.effect === 'ornithopter', true),
given = hold('r', (c) => c.name === 'Snooper');
const at = g.deck.findIndex((c) => c.name === 'Lasgun');
assert.ok(at >= 0);
g.discard.push(...g.deck.splice(at, 1));
return { g, worthless, truth, printed, special, box, distrans, gift, given };
}
type Fixture = ReturnType<typeof fixture>;
function action(g: Game, id: string, a: Action) {
const before = structuredClone(g),
next = applyAction(g, id, a);
assert.deepEqual(g, before);
return next;
}
function passKind(initial: Game, kind: NonNullable<Game['response']>['kind']) {
let g = reload(initial);
for (let tries = 0; g.response?.kind === kind; tries++) {
assert.ok(tries < 12);
const id = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = action(g, id, { type: 'passResponse' });
}
return g;
}
function beginCancel(g: Game, f: Fixture) {
const next = action(g, 'b', {
type: 'card',
card: f.worthless,
mode: 'cancel',
});
assert.equal(next.response?.kind, 'worthlessKarama');
assert.equal(next.pendingKarama?.opportunity?.kind, 'cancel');
return next;
}
function privateViews(g: Game) {
const before = structuredClone(g);
for (const p of g.players) {
const view = viewGame(reload(g), p.id);
assert.equal('pendingKarama' in view, false);
assert.equal(JSON.stringify(view).includes('"signature"'), false);
for (const other of view.players)
if (other.id !== p.id)
for (const key of ['hand', 'spice', 'traitors'])
assert.equal(key in other, false, key);
}
assert.deepEqual(g, before);
}
function inventory(g: Game) {
const ids = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
...(g.auction?.cards.slice(
g.auction.index + (g.currentAuctionSale ? 1 : 0),
) ?? []),
]
.map((c) => c.id)
.sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
function paidBox(g: Game, f: Fixture) {
const paid = action(g, 'r', { type: 'card', card: f.box });
assert.ok(paid.pendingNullentropy);
assert.equal(player(paid, 'r').spice, 18);
privateViews(paid);
return paid;
}
function finishBox(g: Game) {
const pending = g.pendingNullentropy!,
card = g.discard.find((c) => c.name === 'Lasgun')!;
assert.ok(card);
return action(reload(g), 'r', {
type: 'decision',
event: pending.event,
card: card.id,
});
}
function sourceGift(f: Fixture) {
return action(f.g, 'e', { type: 'emperorGift', amount: 4 });
}
function freeBoxSlot(f: Fixture) {
const hand = player(f.g, 'r').hand;
const index = hand.findIndex((card) => card.id === f.given);
assert.ok(index >= 0);
f.g.deck.push(...hand.splice(index, 1));
}
function sourceAuction(f: Fixture) {
freeBoxSlot(f);
let g = f.g;
g.phase = 3;
g.auction = {
cards: g.deck.splice(0, 1),
index: 0,
bid: 0,
bidder: null,
active: 'b',
passed: [],
opener: 0,
};
g = action(g, 'b', { type: 'bid', amount: 2 });
for (const id of ['r', 'e', 'h']) g = action(g, id, { type: 'passBid' });
assert.equal(g.decision?.kind, 'auctionPayment');
g = action(g, 'b', { type: 'decision', karama: false });
assert.equal(g.response?.kind, 'emperorIncome');
return g;
}
void test('an actual gift cancellation preserves hand-changing Distrans, Truthtrance and paid Box interruptions without freezing resources', () => {
const f = fixture(),
source = sourceGift(f),
original = structuredClone(source.response),
ids = inventory(source);
let g = beginCancel(source, f);
const stamped = structuredClone(g.pendingKarama);
g = action(g, 'h', { type: 'passResponse' });
g = action(g, 'r', {
type: 'card',
card: f.distrans,
target: 'b',
give: f.given,
});
assert.deepEqual(g.pendingKarama, stamped);
assert.equal(player(g, 'b').hand.length, 3);
g = action(g, 'b', { type: 'card', card: f.truth });
for (const id of ['r', 'e', 'h']) g = action(g, id, { type: 'truthPass' });
g = action(g, 'b', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'e',
fact: { kind: 'spice', compare: 'eq', value: 20 },
},
});
assert.equal(viewGame(g, 'e').truthAnswer, 'yes');
assert.equal(viewGame(g, 'b').truthAnswer, null);
g = action(g, 'e', { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(g.pendingKarama, stamped);
g = finishBox(paidBox(g, f));
assert.deepEqual(g.pendingKarama, stamped);
assert.deepEqual(g.pendingKarama!.use, {
kind: 'cancel',
response: original,
});
assert.equal(player(g, 'r').spice, 18);
privateViews(g);
g = passKind(g, 'worthlessKarama');
assert.equal(g.pendingKarama ?? null, null);
assert.equal(g.response, null);
assert.equal(player(g, 'e').spice, 20);
assert.equal(player(g, 'h').spice, 20);
assert.equal(g.truthHistory!.length, 1);
assert.deepEqual(inventory(g), ids);
assert.deepEqual(reload(normalizeAutomaticGame(reload(g))), reload(g));
});
void test('canceling a real Richese gift survives Box suspension and retains the source transfer intent without giving its card', () => {
const f = fixture();
freeBoxSlot(f);
let g = action(f.g, 'r', { type: 'richeseGift', card: f.gift });
const original = structuredClone(g.pendingRicheseGift),
ids = inventory(g);
g = beginCancel(g, f);
const stamped = structuredClone(g.pendingKarama);
const paid = paidBox(g, f);
assert.equal(paid.pendingKarama, null);
assert.deepEqual(paid.pendingNullentropy!.resume.pendingKarama, stamped);
assert.deepEqual(paid.pendingRicheseGift, original);
g = finishBox(paid);
assert.deepEqual(g.pendingKarama, stamped);
privateViews(g);
g = passKind(g, 'worthlessKarama');
assert.equal(g.pendingRicheseGift, null);
assert.equal(g.pendingKarama ?? null, null);
assert.equal(
player(g, 'r').hand.some((c) => c.id === f.gift),
true,
);
assert.equal(
player(g, 'b').hand.some((c) => c.id === f.gift),
false,
);
assert.deepEqual(inventory(g), ids);
});
void test('paid auction income cancellation survives a real Harkonnen exchange and Box inside that exchange without replaying purchase or sampling', () => {
const f = fixture();
let g = sourceAuction(f);
const sale = structuredClone(g.currentAuctionSale),
auction = structuredClone(g.auction),
ids = inventory(g);
g = beginCancel(g, f);
const stamped = structuredClone(g.pendingKarama);
g = action(g, 'h', {
type: 'card',
mode: 'special',
card: f.special,
target: 'b',
amount: 1,
});
assert.equal(g.decision?.kind, 'handExchange');
assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama');
assert.deepEqual(g.pendingKarama, stamped);
const sampled = structuredClone(g.players.map((p) => p.hand));
g = finishBox(paidBox(g, f));
assert.deepEqual(g.pendingKarama, stamped);
assert.equal(g.decision?.kind, 'handExchange');
assert.deepEqual(player(g, 'b').hand, sampled[0]);
assert.deepEqual(player(g, 'h').hand, sampled[3]);
assert.deepEqual(g.currentAuctionSale, sale);
assert.deepEqual(g.auction, auction);
privateViews(g);
const returned = player(g, 'h').hand.find((c) => c.name === 'Maula Pistol')!;
g = action(g, 'h', { type: 'decision', returnCards: [returned.id] });
assert.deepEqual(g.pendingKarama, stamped);
g = passKind(g, 'worthlessKarama');
assert.equal(g.pendingKarama ?? null, null);
assert.equal(g.pendingExchange, null);
assert.equal(player(g, 'b').spice, 18);
assert.equal(player(g, 'e').spice, 20);
assert.equal(player(g, 'r').spice, 18);
assert.equal(
g.log.filter((l) => l.text.includes('won a treachery card')).length,
1,
);
assert.equal(g.log.filter((l) => l.text.includes('unseen cards')).length, 1);
assert.deepEqual(inventory(g), ids);
});
void test('source-bound saved cancellations reject replaced response, source auction, transfer and battle contexts while valid legacy metadata-free use still resumes', () => {
for (const source of ['gift', 'auction', 'transfer', 'battle'] as const) {
const f = fixture();
let g = f.g;
if (source === 'gift') g = sourceGift(f);
else if (source === 'auction') g = sourceAuction(f);
else if (source === 'transfer')
g = action(g, 'r', { type: 'richeseGift', card: f.gift });
else {
g.phase = 6;
player(g, 'e').ally = null;
player(g, 'h').ally = null;
g.active = 'h';
g.order = ['h', 'e', 'b', 'r'];
for (const id of ['h', 'e']) {
player(g, id).forces = { 'red_chasm:7': 3 };
player(g, id).reserves = 17;
}
player(g, 'e').elites = {
forces: { 'red_chasm:7': 1 },
reserves: 4,
tanks: 0,
revived: 0,
};
g = action(g, 'h', {
type: 'chooseBattle',
territory: 'red_chasm',
target: 'e',
});
assert.equal(g.response?.kind, 'eliteStrength');
assert.equal(g.response.owner, 'e');
}
g = beginCancel(g, f);
const pending = g.pendingKarama!;
const mutations: ((g: Game) => void)[] = [
(s) => {
s.turn++;
},
(s) => {
s.phase = 7;
},
(s) => {
s.pendingKarama!.owner = 'r';
},
(s) => {
s.response!.owner = 'e';
},
(s) => {
s.pendingKarama!.opportunity!.signature = '{}';
},
(s) => {
const use = s.pendingKarama!.use;
if (use.kind === 'cancel') use.response.owner = 'h';
},
];
if (source === 'gift')
mutations.push(
(s) => {
const use = s.pendingKarama!.use;
if (use.kind === 'cancel') use.response.amount = 5;
},
(s) => {
const use = s.pendingKarama!.use;
if (use.kind === 'cancel') use.response.recipient = 'r';
},
);
if (source === 'auction')
mutations.push(
(s) => {
s.auction!.index++;
},
(s) => {
s.currentAuctionSale!.winner = 'r';
},
);
if (source === 'transfer')
mutations.push(
(s) => {
s.pendingRicheseGift!.intent.cardId = f.box;
},
(s) => {
s.pendingRicheseGift!.event = 'another-gift';
},
);
if (source === 'battle')
mutations.push(
(s) => {
s.battle!.event = 'another-battle';
},
(s) => {
s.battle!.defender = 'r';
},
);
for (const [index, change] of mutations.entries()) {
const bad = reload(g);
change(bad);
const before = structuredClone(bad);
for (const p of bad.players)
assert.throws(() => viewGame(bad, p.id), `${source} ${index} view`);
assert.throws(
() => normalizeAutomaticGame(bad),
`${source} ${index} normalize`,
);
assert.throws(
() => action(bad, 'e', { type: 'passResponse' }),
`${source} ${index} action`,
);
assert.deepEqual(bad, before);
}
const legacy = reload(g);
delete legacy.pendingKarama!.opportunity;
assert.equal(legacy.pendingKarama!.use.kind, 'cancel');
const done = passKind(legacy, 'worthlessKarama');
assert.equal(done.pendingKarama ?? null, null);
assert.equal(done.discard.filter((c) => c.id === f.worthless).length, 1);
assert.equal(pending.opportunity!.kind, 'cancel');
}
});
void test('a real spice worm cancellation survives a separately summoned worm and its added ride before restoring the original source', () => {
const f = fixture();
const fremen = newPlayer('f', 'Fremen', 'fremen');
f.g.players.push(fremen);
f.g.order.push('f');
f.g.phase = 1;
f.g.nexus = false;
f.g.ready = [];
const lands = spiceDeck().filter((c) => 'territory' in c);
const original = lands.find((c) => c.territory === 'the_great_flat')!;
const summoned = 'red_chasm';
const originalKey = location(original.territory, original.sector);
const summonedKey = location(summoned, territory(summoned).sectors[0]);
fremen.forces = { [originalKey]: 2, [summonedKey]: 3 };
fremen.reserves = 15;
const hand = player(f.g, 'h').hand;
const index = hand.findIndex((card) => card.id === f.special);
fremen.hand.push(...hand.splice(index, 1));
const worm = spiceDeck().find((card) => 'worm' in card)!;
f.g.spiceDeck = [worm, ...lands.filter((card) => card !== original)];
f.g.spiceDiscard = [[original], []];
let g = f.g;
for (const id of g.order) g = action(g, id, { type: 'ready' });
assert.equal(g.response?.kind, 'wormSurvival');
assert.equal(g.response?.location, original.territory);
g = beginCancel(g, f);
const conversion = reload(g).pendingKarama;
const sequence = structuredClone(g.spiceSequence);
g = action(g, 'f', {
type: 'card',
mode: 'special',
card: f.special,
territory: summoned,
});
assert.equal(g.response?.kind, 'wormSurvival');
assert.equal(g.response?.location, summoned);
assert.equal(g.pendingKarama ?? null, null);
assert.deepEqual(reload(g).summonedWorm!.resume.pendingKarama, conversion);
assert.notDeepEqual(g.spiceSequence, sequence);
privateViews(g);
g = passKind(g, 'wormSurvival');
assert.equal(g.summonedWorm, null);
assert.deepEqual(reload(g).pendingKarama, conversion);
assert.deepEqual(g.spiceSequence, sequence);
assert.ok(g.wormRides.includes(summoned));
privateViews(g);
g = passKind(g, 'worthlessKarama');
assert.equal(g.pendingKarama ?? null, null);
assert.equal(player(g, 'f').forces[originalKey], undefined);
assert.equal(player(g, 'f').forces[summonedKey], 3);
assert.equal(player(g, 'f').tanks, 2);
assert.equal(g.discard.filter((card) => card.id === f.special).length, 1);
assert.equal(g.discard.filter((card) => card.id === f.worthless).length, 1);
});
void test('a second BG cancellation may resolve separate Richese purchase income and then restore its suspended original cancellation', () => {
const f = fixture();
freeBoxSlot(f);
const richeseHand = player(f.g, 'r').hand;
const unusedDistrans = richeseHand.findIndex(
(card) => card.id === f.distrans,
);
f.g.deck.push(...richeseHand.splice(unusedDistrans, 1));
const harkHand = player(f.g, 'h').hand;
const special = harkHand.findIndex((card) => card.id === f.special);
richeseHand.push(...harkHand.splice(special, 1));
const secondIndex = f.g.deck.findIndex((card) => card.kind === 'worthless');
const second = f.g.deck.splice(secondIndex, 1)[0];
player(f.g, 'b').hand.push(second);
let g = beginCancel(sourceGift(f), f);
const original = reload(g).pendingKarama;
const acquire = g.richeseCache![0].id;
const ids = inventory(g);
g = action(g, 'r', {
type: 'card',
mode: 'special',
card: f.special,
acquire,
});
assert.equal(g.response?.kind, 'richesePurchaseIncome');
assert.deepEqual(
reload(g).pendingRichesePurchaseIncome!.resume.pendingKarama,
original,
);
assert.equal(g.pendingKarama ?? null, null);
g = action(g, 'b', { type: 'card', mode: 'cancel', card: second.id });
assert.equal(g.pendingKarama?.opportunity?.kind, 'cancel');
assert.notEqual(
g.pendingKarama?.opportunity?.signature,
original?.opportunity?.signature,
);
assert.equal(g.pendingKarama?.use.kind, 'cancel');
privateViews(g);
for (let i = 0; g.pendingRichesePurchaseIncome; i++) {
assert.ok(i < 12);
g = action(
reload(g),
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
}
assert.deepEqual(reload(g).pendingKarama, original);
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(player(g, 'e').spice, 20);
assert.equal(player(g, 'r').spice, 17);
assert.equal(
player(g, 'r').hand.filter((card) => card.id === acquire).length,
1,
);
privateViews(g);
g = passKind(g, 'worthlessKarama');
assert.equal(g.pendingKarama ?? null, null);
assert.equal(player(g, 'e').spice, 20);
assert.equal(player(g, 'h').spice, 20);
assert.deepEqual(inventory(g), ids);
for (const id of [f.worthless, second.id, f.special])
assert.equal(g.discard.filter((card) => card.id === id).length, 1);
});