Files
Dune/tests/richese-acquisition.test.ts
T

229 lines
7.8 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
joinGame,
applyAction,
viewGame,
normalizeAutomaticGame,
prepareSpecialKaramaIntent,
executeSpecialKaramaIntent,
type Game,
} from '../game/engine';
import { baseDeck, leaders } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
function fixture(emperor = true) {
let g = createGame('RICHBUY2', newPlayer('r', 'Richese', 'guild'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(g, newPlayer('e', 'Observer', emperor ? 'emperor' : 'atreides'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'r', { type: 'start' });
for (const p of g.players)
if (p.traitorChoices.length)
g = applyAction(g, p.id, {
type: 'traitor',
leader: p.traitorChoices[0],
});
g.players[0].faction = 'richese';
g.players[0].leaders = leaders('richese');
g.players.forEach((p) => {
p.hand = [];
p.spice = 10;
});
g.deck = baseDeck();
g.discard = [];
const k = g.deck.findIndex((c) => c.effect === 'karama');
g.players[0].hand.push(...g.deck.splice(k, 1));
g.richeseCache = richeseCards();
g.richeseRemoved = [];
g.advanced = true;
g.phase = 2;
g.turn = 2;
g.phaseOpening = null;
g.response = null;
g.decision = null;
g.ready = [];
return g;
}
function purchase(g: Game, acquire = 'richese-ornithopter') {
return applyAction(g, 'r', {
type: 'card',
mode: 'special',
card: g.players[0].hand.find((c) => c.effect === 'karama')!.id,
acquire,
});
}
function cancelCard(g: Game, id = 'h') {
const k = g.deck.findIndex((c) => c.effect === 'karama');
g.players.find((p) => p.id === id)!.hand.push(...g.deck.splice(k, 1));
}
function physical(g: Game) {
return [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
...(g.richeseRemoved ?? []),
]
.map((c) => c.id)
.sort();
}
void test('special Karama buys a privately selected cache card and auto-collects separate Emperor income once', () => {
const before = fixture(),
stock = physical(before),
snapshot = JSON.stringify(before);
const g = purchase(before);
assert.equal(JSON.stringify(before), snapshot);
assert.equal(g.players[0].spice, 7);
assert.equal(g.players[2].spice, 13);
assert.equal(g.players[0].specialKaramaUsed, true);
assert.equal(g.players[0].hand[0].id, 'richese-ornithopter');
assert.equal(g.richeseCache!.length, 9);
assert.equal(g.response, null);
assert.equal(g.pendingRichesePurchaseIncome, null);
assert.deepEqual(physical(g), stock);
assert.deepEqual(normalizeAutomaticGame(JSON.parse(JSON.stringify(g))), g);
for (const observer of ['h', 'e']) {
const view = viewGame(g, observer);
assert.equal(view.richeseSpecialKarama, null);
assert.equal(JSON.stringify(view).includes('richese-ornithopter'), false);
}
});
void test('Emperor income can be canceled without reversing or revealing the uncancelable acquisition', () => {
const before = fixture();
cancelCard(before);
let g = purchase(before);
assert.equal(g.response?.kind, 'richesePurchaseIncome');
assert.equal(g.players[2].spice, 10);
const saved = JSON.parse(JSON.stringify(g)) as Game;
g = applyAction(saved, 'h', {
type: 'card',
mode: 'cancel',
card: saved.players[1].hand[0].id,
});
assert.equal(g.players[2].spice, 10);
assert.equal(g.players[0].spice, 7);
assert.equal(g.players[0].hand[0].id, 'richese-ornithopter');
assert.equal(g.players[0].specialKaramaUsed, true);
assert.equal(g.pendingRichesePurchaseIncome, null);
assert.equal(g.response, null);
});
void test('purchase without Emperor pays bank and preserves enclosing decision', () => {
const before = fixture(false);
before.decision = { kind: 'wormPlacement', player: 'h' };
const g = purchase(before);
assert.deepEqual(g.decision, before.decision);
assert.equal(g.players[2].spice, 10);
assert.equal(g.players[0].spice, 7);
});
void test('income continuation restores exact enclosing response and decision after cancellation', () => {
const before = fixture();
cancelCard(before);
// One additional canceller keeps the restored outer response open.
cancelCard(before, 'e');
before.response = {
kind: 'voice',
owner: 'r',
passed: ['e'],
intent: 'Outer response',
};
before.decision = { kind: 'wormPlacement', player: 'h' };
let g = purchase(before);
assert.equal(g.decision, null);
assert.deepEqual(
g.pendingRichesePurchaseIncome?.resume.response,
before.response,
);
g = applyAction(g, 'h', { type: 'passResponse' });
// Emperor owns the income and cannot cancel it; outer h retains Karama.
assert.deepEqual(g.response, before.response);
assert.deepEqual(g.decision, before.decision);
assert.equal(g.players[2].spice, 13);
});
void test('private options validate exactly and do not mutate state or expose cache to opponents', () => {
const g = fixture(),
before = JSON.stringify(g);
const own = viewGame(g, 'r').richeseSpecialKarama!;
assert.equal(own.cards.length, 10);
assert.equal(own.karamas.length, 1);
assert.equal(own.blocked, null);
assert.equal(own.payee, 'e');
assert.equal(JSON.stringify(g), before);
assert.equal(viewGame(g, 'h').richeseSpecialKarama, null);
assert.throws(() => purchase(g, 'not-a-cache-card'), /verified card/);
g.richeseCache![0].name = 'Forged face';
assert.throws(() => purchase(g), /verified card/);
});
void test('full-hand special purchase exchanges the spent Karama and may exhaust the cache under the resolved auction rule', () => {
const start = fixture();
start.players[0].hand.push(...start.deck.splice(0, 3));
const spent = start.players[0].hand.find(card => card.effect === 'karama')!.id;
const chosen = start.richeseCache!.find(card => card.id === 'richese-ornithopter')!;
start.richeseRemoved!.push(...start.richeseCache!.filter(card => card.id !== chosen.id));
start.richeseCache = [chosen];
const held = start.players[0].hand.filter(card => card.id !== spent).map(card => card.id);
const result = purchase(start, chosen.id);
assert.equal(result.players[0].hand.length, 4);
assert.ok(held.every(id => result.players[0].hand.some(card => card.id === id)));
assert.ok(result.players[0].hand.some(card => card.id === chosen.id));
assert.equal(result.discard.filter(card => card.id === spent).length, 1);
assert.equal(result.richeseCache!.length, 0);
assert.equal(result.players[0].spice, 7);
assert.equal(result.players[2].spice, 13);
assert.equal(result.players[0].specialKaramaUsed, true);
});
void test('insufficient or pledged spice, Basic mode, used power and committed Karama are rejected atomically', () => {
for (const configure of [
(g: Game) => {
g.players[0].spice = 2;
},
(g: Game) => {
g.advanced = false;
},
(g: Game) => {
g.players[0].specialKaramaUsed = true;
},
(g: Game) => {
g.auction = {
cards: [g.deck.pop()!],
index: 0,
bid: 8,
bidder: 'r',
active: 'h',
passed: [],
opener: 0,
};
},
]) {
const g = fixture();
configure(g);
const before = JSON.stringify(g);
assert.throws(() => purchase(g));
assert.equal(JSON.stringify(g), before);
}
});
void test('normalized private intent revalidates exact selected card and window before execution', () => {
const g = fixture();
const intent = prepareSpecialKaramaIntent(g, 'r', {
type: 'card',
mode: 'special',
card: g.players[0].hand[0].id,
acquire: 'richese-ornithopter',
});
const changed = structuredClone(g);
changed.richeseCache!.shift();
assert.throws(
() => executeSpecialKaramaIntent(changed, intent),
/verified card/,
);
const later = structuredClone(g);
later.phase++;
assert.throws(
() => executeSpecialKaramaIntent(later, intent),
/earlier turn or phase/,
);
});