526 lines
19 KiB
TypeScript
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);
|
|
});
|