274 lines
9.3 KiB
TypeScript
274 lines
9.3 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
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 inventory(g: Game) {
|
|
const ids = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...g.players.flatMap((p) => p.hand),
|
|
...(g.richeseCache ?? []),
|
|
...(g.richeseRemoved ?? []),
|
|
...(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 action(g: Game, id: string, a: Action) {
|
|
const snapshot = structuredClone(g),
|
|
physical = inventory(g);
|
|
const next = applyAction(g, id, a);
|
|
assert.deepEqual(g, snapshot);
|
|
assert.deepEqual(inventory(next), physical);
|
|
return reload(next);
|
|
}
|
|
function hold(g: Game, id: string, effect: string, cache = false) {
|
|
const pile = cache ? g.richeseCache! : g.deck;
|
|
const i = pile.findIndex((c) => c.effect === effect || c.kind === effect);
|
|
assert.ok(i >= 0, effect);
|
|
const card = pile.splice(i, 1)[0];
|
|
player(g, id).hand.push(card);
|
|
return card.id;
|
|
}
|
|
/** Physically conserved isolated Bidding scenario; the BG conversion and every
|
|
* nested interaction below are real production actions, not fabricated stamps. */
|
|
function fixture(karamaOwner = 'h') {
|
|
let g = createGame('BGNESTED', newPlayer('b', 'BG', 'beneGesserit'), true, [
|
|
'choam',
|
|
]);
|
|
g.players.push(
|
|
newPlayer('r', 'Richese', 'richese'),
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('h', 'Hark', 'harkonnen'),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 3,
|
|
turn: 2,
|
|
active: 'e',
|
|
order: ['e', 'b', 'r', 'h'],
|
|
deck: baseDeck(),
|
|
richeseCache: richeseCards(),
|
|
});
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 20;
|
|
p.forces = {};
|
|
p.reserves = 20;
|
|
p.traitors = [];
|
|
p.traitorChoices = [];
|
|
}
|
|
player(g, 'b').ally = 'r';
|
|
player(g, 'r').ally = 'b';
|
|
const worthless = hold(g, 'b', 'worthless');
|
|
hold(g, 'e', 'karama');
|
|
const special = hold(g, karamaOwner, 'karama');
|
|
// For the purchase-income probe, a second physical Worthless makes the
|
|
// Emperor's nested response cancellable by a non-owner, so it really pauses.
|
|
hold(g, 'b', karamaOwner === 'r' ? 'worthless' : 'shield');
|
|
hold(g, 'b', 'snooper');
|
|
const box = hold(g, 'r', 'nullentropyBox', true),
|
|
gift = hold(g, 'r', 'ornithopter', true);
|
|
hold(g, 'h', 'truthtrance');
|
|
const lot = g.deck.shift()!;
|
|
g.auction = {
|
|
cards: [lot],
|
|
index: 0,
|
|
bid: 0,
|
|
bidder: null,
|
|
active: 'e',
|
|
passed: [],
|
|
opener: 0,
|
|
};
|
|
g = action(g, 'b', { type: 'card', card: worthless, mode: 'purchase' });
|
|
assert.ok(g.pendingKarama?.opportunity);
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
assert.equal(player(g, 'b').hand.length, 2);
|
|
return { g, worthless, special, box, gift, lot: lot.id };
|
|
}
|
|
function passKind(initial: Game, kind: NonNullable<Game['response']>['kind']) {
|
|
let g = reload(initial);
|
|
for (let n = 0; g.response?.kind === kind; n++) {
|
|
assert.ok(n < 30);
|
|
g = action(
|
|
g,
|
|
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
|
|
{ type: 'passResponse' },
|
|
);
|
|
}
|
|
return g;
|
|
}
|
|
function privateViews(g: Game) {
|
|
const before = structuredClone(g);
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
assert.ok(!('pendingKarama' in view));
|
|
assert.equal(JSON.stringify(view).includes('opportunity'), false);
|
|
for (const other of view.players)
|
|
if (other.id !== p.id) assert.equal(other.hand, undefined);
|
|
}
|
|
assert.deepEqual(g, before);
|
|
const normalized = normalizeAutomaticGame(reload(g));
|
|
assert.deepEqual(
|
|
reload(normalizeAutomaticGame(reload(normalized))),
|
|
reload(normalized),
|
|
);
|
|
}
|
|
function exchange(initial: Game, card: string) {
|
|
const g = action(initial, 'h', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card,
|
|
target: 'b',
|
|
amount: 1,
|
|
});
|
|
assert.equal(g.decision?.kind, 'handExchange');
|
|
assert.equal(g.response, null);
|
|
assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama');
|
|
assert.ok(g.pendingKarama?.opportunity);
|
|
assert.equal(player(g, 'b').hand.length, 1);
|
|
return g;
|
|
}
|
|
function finishExchange(initial: Game) {
|
|
const returned = player(initial, 'h').hand.find(
|
|
(c) => c.effect !== 'truthtrance',
|
|
)!;
|
|
assert.ok(returned);
|
|
return action(initial, 'h', { type: 'decision', returnCards: [returned.id] });
|
|
}
|
|
function purchased(initial: Game, lot: string, count: number) {
|
|
const done = passKind(initial, 'worthlessKarama');
|
|
assert.equal(done.pendingKarama, null);
|
|
assert.equal(player(done, 'b').hand.filter((c) => c.id === lot).length, 1);
|
|
assert.equal(player(done, 'b').hand.length, count);
|
|
assert.equal(done.phase, 4);
|
|
assert.equal(player(done, 'b').spice, 20);
|
|
assert.equal(done.currentAuctionSale, null);
|
|
privateViews(done);
|
|
return done;
|
|
}
|
|
|
|
void test('Box inside a Richese gift preserves the original BG purchase conversion and its incoming slot', () => {
|
|
const f = fixture();
|
|
let g = action(f.g, 'r', { type: 'richeseGift', card: f.gift });
|
|
assert.ok(g.pendingRicheseGift?.resume.pendingKarama?.opportunity);
|
|
g = action(g, 'r', { type: 'card', card: f.box });
|
|
assert.equal(
|
|
g.pendingNullentropy,
|
|
null,
|
|
'the spent Worthless is the sole discard candidate',
|
|
);
|
|
assert.equal(g.response?.kind, 'richeseGift');
|
|
assert.ok(g.pendingRicheseGift?.resume.pendingKarama?.opportunity);
|
|
assert.ok(player(g, 'r').hand.some((c) => c.id === f.worthless));
|
|
privateViews(g);
|
|
g = passKind(g, 'richeseGift');
|
|
assert.equal(player(g, 'b').hand.length, 3);
|
|
const done = purchased(g, f.lot, 4);
|
|
assert.equal(player(done, 'r').spice, 18);
|
|
assert.equal(done.discard.filter((c) => c.id === f.box).length, 1);
|
|
});
|
|
|
|
void test('Harkonnen hand exchange restores the stamped conversion response before the original free purchase', () => {
|
|
const f = fixture();
|
|
let g = exchange(f.g, f.special);
|
|
privateViews(g);
|
|
g = finishExchange(g);
|
|
assert.equal(g.pendingExchange, null);
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
const done = purchased(g, f.lot, 3);
|
|
assert.equal(done.discard.filter((c) => c.id === f.special).length, 1);
|
|
assert.equal(player(done, 'h').specialKaramaUsed, true);
|
|
});
|
|
|
|
void test('Richese special-purchase income can suspend and restore the BG purchase conversion independently', () => {
|
|
const f = fixture('r'),
|
|
acquire = f.g.richeseCache![0].id;
|
|
let g = action(f.g, 'r', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: f.special,
|
|
acquire,
|
|
});
|
|
assert.equal(g.response?.kind, 'richesePurchaseIncome');
|
|
assert.ok(g.pendingRichesePurchaseIncome?.resume.pendingKarama?.opportunity);
|
|
assert.equal(player(g, 'r').spice, 17);
|
|
assert.equal(player(g, 'e').spice, 20);
|
|
privateViews(g);
|
|
g = passKind(g, 'richesePurchaseIncome');
|
|
assert.equal(player(g, 'e').spice, 23);
|
|
assert.equal(g.pendingRichesePurchaseIncome, null);
|
|
const done = purchased(g, f.lot, 3);
|
|
assert.equal(
|
|
player(done, 'r').hand.filter((c) => c.id === acquire).length,
|
|
1,
|
|
);
|
|
assert.equal(player(done, 'e').spice, 23);
|
|
});
|
|
|
|
void test('Box during a hand exchange resolves the conversion response through its suspended exchange parent', () => {
|
|
const f = fixture();
|
|
let g = exchange(f.g, f.special);
|
|
g = action(g, 'r', { type: 'card', card: f.box });
|
|
assert.ok(g.pendingNullentropy);
|
|
assert.equal(g.pendingNullentropy.resume.response, null);
|
|
assert.equal(g.pendingNullentropy.resume.decision?.kind, 'handExchange');
|
|
assert.ok(g.pendingNullentropy.resume.pendingKarama?.opportunity);
|
|
assert.equal(g.pendingExchange?.response?.kind, 'worthlessKarama');
|
|
privateViews(g);
|
|
g = action(g, 'r', {
|
|
type: 'decision',
|
|
event: g.pendingNullentropy.event,
|
|
card: f.worthless,
|
|
});
|
|
assert.equal(g.decision?.kind, 'handExchange');
|
|
assert.equal(g.response, null);
|
|
assert.ok(g.pendingKarama?.opportunity);
|
|
privateViews(g);
|
|
g = finishExchange(g);
|
|
const done = purchased(g, f.lot, 3);
|
|
assert.ok(player(done, 'r').hand.some((c) => c.id === f.worthless));
|
|
assert.equal(done.discard.filter((c) => c.id === f.box).length, 1);
|
|
});
|
|
|
|
void test('one gift fits both a pending hand return and auction purchase, while a second gift is rejected before transfer', () => {
|
|
const f = fixture();
|
|
let g = exchange(f.g, f.special);
|
|
g = action(g, 'r', { type: 'richeseGift', card: f.gift });
|
|
assert.equal(g.pendingRicheseGift?.resume.decision?.kind, 'handExchange');
|
|
assert.ok(g.pendingRicheseGift?.resume.pendingKarama?.opportunity);
|
|
privateViews(g);
|
|
g = passKind(g, 'richeseGift');
|
|
assert.equal(player(g, 'b').hand.length, 2);
|
|
assert.equal(g.decision?.kind, 'handExchange');
|
|
const before = structuredClone(g),
|
|
physical = inventory(g);
|
|
assert.throws(
|
|
() => applyAction(g, 'r', { type: 'richeseGift', card: f.box }),
|
|
/room|hand|purchase/i,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(inventory(g), physical);
|
|
assert.ok(player(g, 'r').hand.some((c) => c.id === f.box));
|
|
assert.equal(g.pendingRicheseGift, null);
|
|
g = finishExchange(g);
|
|
assert.equal(player(g, 'b').hand.length, 3);
|
|
purchased(g, f.lot, 4);
|
|
});
|