Files
Dune/tests/ix-technology.test.ts
T
2026-09-08 07:37:52 +10:00

526 lines
19 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function fixture(advanced = false) {
const g = createGame(
'IXTECH22',
newPlayer('i', 'Ixians', 'ixians'),
advanced,
['ix'],
);
g.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('a', 'Atreides', 'atreides'),
);
g.players.forEach((p) => {
p.spice = 20;
p.traitors = [p.leaders[0].id];
});
g.status = 'playing';
g.phase = 2;
g.order = g.players.map((p) => p.id);
g.deck = baseDeck();
return g;
}
function hold(g: Game, id: string, predicate: (c: Card) => boolean) {
const index = g.deck.findIndex(predicate);
assert.ok(index >= 0);
const card = g.deck.splice(index, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(card);
return card;
}
function ready(state: Game) {
let g = state;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function auction(state = fixture()) {
return ready(ready(state));
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function pool(state = fixture(), position: 'top' | 'bottom' = 'bottom') {
const g = allow(auction(state));
assert.equal(g.decision?.kind, 'ixAuction');
return applyAction(g, 'i', {
type: 'decision',
card: g.ixAuction!.cards[0].id,
position,
});
}
function payment(buyer = 'a', advanced = false) {
const g = fixture(advanced);
g.phase = 3;
if (buyer === 'h') g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
const p = g.players.find((p) => p.id === buyer)!;
p.spice = 20;
p.ally = 'i';
g.players[0].ally = buyer;
g.order = g.players.map((p) => p.id);
g.active = buyer;
const won = g.deck.shift()!;
g.auction = {
cards: [won],
index: 0,
bid: 2,
bidder: buyer,
active: buyer,
passed: [],
opener: 0,
};
g.decision = { kind: 'auctionPayment', player: buyer };
return g;
}
function liveCards(g: Game) {
return [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)]
.map((c) => c.id)
.sort();
}
void test('Ixian inspection has a public cancellation window before drawing or revealing any pool cards', () => {
const initial = fixture();
hold(initial, 'e', (c) => c.effect === 'karama');
const deck = structuredClone(initial.deck);
const g = auction(initial);
assert.equal(g.response?.kind, 'ixAuction');
assert.deepEqual(g.deck, deck);
assert.equal(g.auction, null);
assert.equal(viewGame(g, 'i').ixTechnology?.pool, null);
assert.equal(viewGame(g, 'a').ixTechnology, null);
assert.throws(
() =>
applyAction(g, 'i', {
type: 'decision',
card: deck[0].id,
position: 'top',
}),
/response window/,
);
});
void test('a full Ixian hand still permits inspection and the pool count uses only eligible bidders', () => {
const initial = fixture();
initial.players[0].hand = initial.deck.splice(0, 4);
let g = allow(auction(initial));
assert.equal(g.ixAuction!.cards.length, 3);
g = applyAction(g, 'i', {
type: 'decision',
card: g.ixAuction!.cards[0].id,
position: 'bottom',
});
assert.equal(g.auction!.cards.length, 2);
assert.equal(g.auction!.active, 'e');
});
void test('Karama cancellation draws only the ordinary pool and does not expose it to Ixians', () => {
const initial = fixture();
const karama = hold(initial, 'e', (c) => c.effect === 'karama');
const expected = initial.deck.slice(0, 3);
let g = auction(initial);
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karama.id });
assert.deepEqual(g.auction!.cards, expected);
assert.equal(g.deck.length, initial.deck.length - 3);
assert.equal(g.ixAuction, null);
assert.deepEqual(viewGame(g, 'i').ixTechnology?.known, []);
assert.equal(g.response, null);
assert.equal(viewGame(g, 'a').auction?.card?.id, expected[0].id);
g = allow(g);
assert.equal(viewGame(g, 'a').auction?.card?.id, expected[0].id);
});
void test('top and bottom choices preserve the rest of the deck and conceal both choice and shuffled order', () => {
for (const position of ['top', 'bottom'] as const) {
let g = allow(auction());
const cards = [...g.ixAuction!.cards],
rest = [...g.deck],
selected = cards[1];
assert.equal(viewGame(g, 'a').ixTechnology, null);
g = applyAction(JSON.parse(JSON.stringify(g)), 'i', {
type: 'decision',
card: selected.id,
position,
});
assert.deepEqual(
g.deck,
position === 'top' ? [selected, ...rest] : [...rest, selected],
);
assert.deepEqual(
g.auction!.cards.map((c) => c.id).sort(),
cards
.filter((c) => c.id !== selected.id)
.map((c) => c.id)
.sort(),
);
const own = viewGame(g, 'i');
assert.equal(own.auction?.card, null);
assert.deepEqual(
own.ixTechnology?.known.map((c) => c.id),
cards
.filter((c) => c.id !== selected.id)
.map((c) => c.id)
.sort(),
);
const outsider = JSON.stringify(viewGame(g, 'e'));
for (const card of cards) assert.equal(outsider.includes(card.id), false);
assert.equal(g.log.at(-1)!.text.includes(position), true);
assert.equal(g.log.at(-1)!.text.includes(selected.name), false);
}
});
void test('invalid or unauthorized pool choices cannot mutate the state', () => {
const g = allow(auction()),
before = structuredClone(g);
for (const [id, card, position] of [
['e', g.ixAuction!.cards[0].id, 'top'],
['i', 'not-in-pool', 'top'],
['i', g.ixAuction!.cards[0].id, 'middle'],
]) {
assert.throws(() =>
applyAction(g, id, { type: 'decision', card, position }),
);
assert.deepEqual(g, before);
}
});
void test('advanced substitution occurs before Atreides inspection and exchanges exactly one private hand card', () => {
const initial = fixture(true);
hold(initial, 'e', (c) => c.effect === 'karama');
const offered = hold(initial, 'i', (c) => c.kind === 'worthless');
let g = pool(initial);
const incoming = g.auction!.cards[0];
assert.equal(g.decision?.kind, 'ixTechnology');
assert.equal(g.response, null);
assert.equal(viewGame(g, 'a').auction?.card, null);
g = applyAction(g, 'i', { type: 'decision', card: offered.id });
assert.equal(g.response?.kind, 'ixTechnology');
assert.equal(g.auction!.cards[0].id, incoming.id);
assert.equal(JSON.stringify(viewGame(g, 'e')).includes(offered.id), false);
g = allow(JSON.parse(JSON.stringify(g)));
assert.equal(g.players[0].hand.length, 1);
assert.equal(g.players[0].hand[0].id, incoming.id);
assert.equal(g.auction!.cards[0].id, offered.id);
assert.equal(viewGame(g, 'a').auction?.card?.id, offered.id);
assert.equal(viewGame(g, 'i').ixTechnology?.used, true);
assert.equal(viewGame(g, 'i').auction?.card, null);
});
void test('canceling substitution preserves both cards and consumes the once-per-round attempt without leaking its result', () => {
const initial = fixture(true);
const offered = hold(initial, 'i', (c) => c.kind === 'worthless');
const cancel = hold(initial, 'e', (c) => c.effect === 'karama');
let g = pool(initial);
const incoming = g.auction!.cards[0];
g = applyAction(g, 'i', { type: 'decision', card: offered.id });
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: cancel.id });
assert.equal(g.players[0].hand[0].id, offered.id);
assert.equal(g.auction!.cards[0].id, incoming.id);
assert.equal(g.ixTechnologyTurn, g.turn);
assert.equal(g.pendingIxTechnology, null);
assert.equal(g.response, null);
assert.equal(viewGame(g, 'a').auction?.card?.id, incoming.id);
});
void test('declining substitution saves it for the next card, while using it removes later offers', () => {
for (const use of [false, true]) {
const initial = fixture(true);
const offered = hold(initial, 'i', (c) => c.kind === 'worthless');
let g = pool(initial);
g = allow(
applyAction(
g,
'i',
use
? { type: 'decision', card: offered.id }
: { type: 'decision', decline: true },
),
);
g = applyAction(g, 'i', { type: 'bid', amount: 1 });
while (g.auction?.index === 0)
g = applyAction(g, g.auction!.active, { type: 'passBid' });
assert.notEqual(g.decision?.kind, 'auctionPayment');
assert.equal(g.players[0].spice, 19);
g = allow(g);
assert.equal(g.auction!.index, 1);
assert.equal(g.decision?.kind, use ? undefined : 'ixTechnology');
}
});
void test('starting-card choice deals the shuffled remainder privately and gives Harkonnen an extra deck card', () => {
let g = fixture();
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
g.players[3].traitors = [g.players[3].leaders[0].id];
g.status = 'setup';
g.ixSetupCards = g.deck.splice(0, 4);
g.decision = { kind: 'ixSetup', player: 'i' };
const selected = g.ixSetupCards[2],
remainder = g.ixSetupCards.filter((c) => c.id !== selected.id),
bonus = g.deck[0];
assert.deepEqual(viewGame(g, 'i').ixTechnology?.setup, g.ixSetupCards);
const other = JSON.stringify(viewGame(g, 'e'));
for (const c of g.ixSetupCards) assert.equal(other.includes(c.id), false);
assert.throws(() =>
applyAction(g, 'e', { type: 'decision', card: selected.id }),
);
g = applyAction(JSON.parse(JSON.stringify(g)), 'i', {
type: 'decision',
card: selected.id,
});
assert.equal(g.players[0].hand[0].id, selected.id);
assert.deepEqual(
g.players
.slice(1)
.flatMap((p) => p.hand)
.filter((c) => c.id !== bonus.id)
.map((c) => c.id)
.sort(),
remainder.map((c) => c.id).sort(),
);
assert.equal(g.players[3].hand.length, 2);
assert.ok(g.players[3].hand.some((c) => c.id === bonus.id));
assert.equal(g.ixSetupCards, null);
assert.equal(g.status, 'playing');
assert.ok(g.phaseOpening);
assert.deepEqual(
liveCards(g),
baseDeck()
.map((c) => c.id)
.sort(),
);
});
void test('an Ixian ally decides only on its purchased card, after paying and before Emperor income', () => {
let g = payment();
const won = g.auction!.cards[0];
const old = hold(g, 'a', (c) => c.kind === 'snooper');
const replacement = g.deck[0];
g = applyAction(g, 'a', { type: 'decision', karama: false });
assert.equal(g.players[2].spice, 18);
assert.equal(g.players[1].spice, 20);
assert.equal(g.decision?.kind, 'ixAllyCard');
assert.equal(viewGame(g, 'a').ixPurchased?.id, won.id);
assert.equal(viewGame(g, 'i').ixPurchased, null);
g = applyAction(g, 'a', { type: 'decision', accept: true });
assert.equal(g.response, null);
g = allow(g);
assert.ok(g.players[2].hand.some((c) => c.id === replacement.id));
assert.ok(g.players[2].hand.some((c) => c.id === old.id));
assert.ok(g.discard.some((c) => c.id === won.id));
assert.equal(g.players[1].spice, 22);
assert.equal(g.pendingIxAlly, null);
assert.deepEqual(
liveCards(g),
baseDeck()
.map((c) => c.id)
.sort(),
);
});
void test('keeping or canceling an allied replacement retains the purchase and still settles payment once', () => {
for (const mode of ['keep', 'cancel'] as const) {
let g = payment();
const won = g.auction!.cards[0];
const card = hold(g, 'e', (c) => c.effect === 'karama');
g = applyAction(g, 'a', { type: 'decision', karama: false });
g = applyAction(g, 'a', { type: 'decision', accept: mode === 'cancel' });
if (mode === 'cancel')
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: card.id });
g = allow(g);
assert.ok(g.players[2].hand.some((c) => c.id === won.id));
assert.equal(g.players[1].spice, 22);
assert.equal(g.players[2].spice, 18);
}
});
void test('Harkonnen ally replaces the purchased card before receiving its separate bonus', () => {
let g = payment('h');
const replacement = g.deck[0],
bonus = g.deck[1],
won = g.auction!.cards[0];
g = applyAction(g, 'h', { type: 'decision', karama: false });
g = applyAction(g, 'h', { type: 'decision', accept: true });
g = allow(g);
assert.deepEqual(
g.players[3].hand.map((c) => c.id),
[replacement.id, bonus.id],
);
assert.ok(g.discard.some((c) => c.id === won.id));
assert.equal(g.pendingIxAlly, null);
assert.equal(g.decision, null);
assert.deepEqual(
liveCards(g),
baseDeck()
.map((c) => c.id)
.sort(),
);
});
void test('a Karama-paid purchase can be replaced but generates no Emperor income', () => {
let g = payment();
const card = hold(g, 'a', (c) => c.effect === 'karama');
g = applyAction(g, 'a', { type: 'decision', karama: true, card: card.id });
assert.equal(g.decision?.kind, 'ixAllyCard');
g = allow(applyAction(g, 'a', { type: 'decision', accept: true }));
assert.equal(g.players[2].spice, 20);
assert.equal(g.players[1].spice, 20);
assert.deepEqual(
liveCards(g),
baseDeck()
.map((c) => c.id)
.sort(),
);
});
void test('Bene Gesserit conversion cancellation restores the private Ixian substitution window', () => {
const initial = fixture(true);
initial.players[1].faction = 'beneGesserit';
const offered = hold(initial, 'i', (c) => c.kind === 'worthless');
const worthless = hold(initial, 'e', (c) => c.kind === 'worthless');
const karama = hold(initial, 'a', (c) => c.effect === 'karama');
let g = pool(initial);
g = applyAction(g, 'i', { type: 'decision', card: offered.id });
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: worthless.id });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.equal(JSON.stringify(viewGame(g, 'a')).includes(offered.id), false);
const incoming = g.auction!.cards[0];
g = applyAction(g, 'a', { type: 'card', mode: 'cancel', card: karama.id });
assert.equal(g.response, null);
assert.equal(g.pendingIxTechnology, null);
g = allow(g);
assert.ok(g.players[0].hand.some((c) => c.id === incoming.id));
});
void test('exhausted or one-card decks cannot leave an impossible pool-selection decision', () => {
for (const n of [0, 1]) {
const initial = fixture();
initial.deck = initial.deck.slice(0, n);
const g = allow(auction(initial));
assert.equal(g.ixAuction, null);
assert.equal(g.decision, null);
assert.equal(g.auction?.cards.length ?? 0, n);
}
});
void test('all AI levels handle starting selection and a complete advanced Ixian bidding phase', () => {
for (const difficulty of DIFFICULTIES) {
let g = fixture(true);
g.players.forEach((p) => {
p.bot = difficulty;
});
hold(g, 'i', (c) => c.kind === 'worthless');
g.players[0].ally = 'a';
g.players[2].ally = 'i';
g = auction(g);
for (let i = 0; g.phase === 3 && i < 300; i++) g = runBots(g, 1);
assert.equal(g.phase, 4, difficulty);
assert.deepEqual(
liveCards(g),
baseDeck()
.map((c) => c.id)
.sort(),
);
g = fixture();
g.status = 'setup';
g.players[0].bot = difficulty;
g.ixSetupCards = g.deck.splice(0, 3);
g.decision = { kind: 'ixSetup', player: 'i' };
const choice = botActions(viewGame(g, 'i'))[0];
assert.ok(choice);
g = applyAction(g, 'i', choice);
assert.equal(g.players[0].hand.length, 1);
}
});
void test('no eligible bidder skips inspection; empty Ixian hands skip only the advanced swap offer', () => {
const initial = fixture();
for (const p of initial.players) p.hand = initial.deck.splice(0, 4);
let g = auction(initial);
assert.equal(g.phase, 4);
assert.equal(g.ixAuction, undefined);
assert.equal(g.response, null);
g = pool(fixture(true));
assert.equal(g.decision, null);
assert.equal(g.response, null);
assert.ok(viewGame(g, 'a').auction?.card);
});
void test('canceling auction inspection does not also cancel advanced substitution', () => {
const initial = fixture(true);
const offered = hold(initial, 'i', (c) => c.kind === 'worthless'),
karama = hold(initial, 'e', (c) => c.effect === 'karama');
let g = auction(initial);
g = applyAction(g, 'e', { type: 'card', mode: 'cancel', card: karama.id });
assert.equal(g.decision?.kind, 'ixTechnology');
assert.deepEqual(viewGame(g, 'i').ixTechnology?.known, []);
const incoming = g.auction!.cards[0];
g = allow(applyAction(g, 'i', { type: 'decision', card: offered.id }));
assert.equal(g.players[0].hand[0].id, incoming.id);
assert.equal(viewGame(g, 'a').auction?.card?.id, offered.id);
});
void test('a Harkonnen exchange cannot strand a substitution whose offered card changed hands', () => {
const initial = fixture(true);
initial.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
initial.order.push('h');
const offered = hold(initial, 'i', (c) => c.kind === 'worthless'),
karama = hold(initial, 'h', (c) => c.effect === 'karama'),
returned = hold(initial, 'h', (c) => c.kind === 'hero');
let g = pool(initial);
const incoming = g.auction!.cards[0];
g = applyAction(g, 'i', { type: 'decision', card: offered.id });
g = applyAction(g, 'h', {
type: 'card',
mode: 'special',
card: karama.id,
target: 'i',
amount: 1,
});
assert.equal(g.decision?.kind, 'handExchange');
g = applyAction(g, 'h', { type: 'decision', returnCards: [returned.id] });
assert.equal(g.response, null);
g = allow(g);
assert.equal(g.auction!.cards[0].id, incoming.id);
assert.equal(g.players[0].hand[0].id, returned.id);
assert.equal(g.pendingIxTechnology, null);
assert.equal(g.ixTechnologyTurn, g.turn);
});
void test('a purchased card removed by hand exchange cannot be replaced through a stale ally decision', () => {
let g = payment('a', true);
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'));
g.order.push('h');
const won = g.auction!.cards[0],
karama = hold(g, 'h', (c) => c.effect === 'karama'),
returned = hold(g, 'h', (c) => c.kind === 'hero');
g = applyAction(g, 'a', { type: 'decision', karama: false });
g = applyAction(g, 'h', {
type: 'card',
mode: 'special',
card: karama.id,
target: 'a',
amount: 1,
});
g = applyAction(g, 'h', { type: 'decision', returnCards: [returned.id] });
assert.equal(g.decision?.kind, 'ixAllyCard');
assert.equal(viewGame(g, 'a').ixPurchased, null);
assert.throws(
() => applyAction(g, 'a', { type: 'decision', accept: true }),
/must remain available/,
);
g = allow(applyAction(g, 'a', { type: 'decision', accept: false }));
assert.ok(g.players[3].hand.some((c) => c.id === won.id));
assert.equal(g.players[1].spice, 22);
assert.equal(g.pendingIxAlly, null);
});
void test('a returned card already in the inspected pool does not duplicate private knowledge entries', () => {
let g = pool(fixture(true)); // Empty Ixian hand bypasses the initial swap choice.
g = allow(g);
const previous = g.auction!.cards[0];
g = applyAction(g, 'i', { type: 'bid', amount: 1 });
while (g.auction?.index === 0)
g = applyAction(g, g.auction!.active, { type: 'passBid' });
assert.notEqual(g.decision?.kind, 'auctionPayment');
assert.equal(g.players[0].spice, 19);
g = allow(g);
assert.equal(g.decision?.kind, 'ixTechnology');
g = allow(applyAction(g, 'i', { type: 'decision', card: previous.id }));
const known = viewGame(g, 'i').ixTechnology!.known;
assert.equal(new Set(known.map((c) => c.id)).size, known.length);
});