359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createGame,
|
|
newPlayer,
|
|
applyAction,
|
|
viewGame,
|
|
normalizeAutomaticGame,
|
|
type Game,
|
|
type Action,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import {
|
|
createRicheseAuction,
|
|
submitRicheseBid,
|
|
} from '../game/richese-auction';
|
|
import type { FactionId } from '../game/catalog';
|
|
function fixture(other: FactionId = 'emperor') {
|
|
const g = createGame(
|
|
'GIFTREVIEW',
|
|
newPlayer('r', 'Richese', 'richese'),
|
|
true,
|
|
['choam'],
|
|
);
|
|
g.players.push(
|
|
newPlayer('a', 'Ally', 'atreides'),
|
|
newPlayer('e', 'Other', other),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 3;
|
|
g.turn = 2;
|
|
g.active = 'a';
|
|
g.order = ['r', 'a', 'e'];
|
|
g.deck = baseDeck();
|
|
g.richeseCache = richeseCards();
|
|
for (const p of g.players) {
|
|
p.spice = 20;
|
|
p.hand = [];
|
|
p.forces = {};
|
|
}
|
|
g.players[0].ally = 'a';
|
|
g.players[1].ally = 'r';
|
|
const card = g.richeseCache.find((c) => c.effect === 'karama')!;
|
|
g.richeseCache = g.richeseCache.filter((c) => c.id !== card.id);
|
|
g.players[0].hand.push(card);
|
|
return g;
|
|
}
|
|
function hold(g: Game, owner: string, name: string) {
|
|
const i = g.deck.findIndex((c) => c.name === name);
|
|
assert.ok(i >= 0, name);
|
|
const c = g.deck.splice(i, 1)[0];
|
|
g.players.find((p) => p.id === owner)!.hand.push(c);
|
|
return c.id;
|
|
}
|
|
const send = (g: Game, id: string, action: Action) =>
|
|
applyAction(g, id, action);
|
|
const gift = (g: Game) =>
|
|
send(g, 'r', { type: 'richeseGift', card: 'richese-karama' });
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function handIds(g: Game) {
|
|
return g.players.map((p) => ({ id: p.id, cards: p.hand.map((c) => c.id) }));
|
|
}
|
|
|
|
void test('a changed mutual ally during a paused gift cannot replace the original recipient hand with the new ally hand', () => {
|
|
let g = fixture();
|
|
hold(g, 'e', 'Karama');
|
|
hold(g, 'a', 'Shield');
|
|
hold(g, 'e', 'Maula Pistol');
|
|
g = gift(g);
|
|
g.players[0].ally = 'e';
|
|
g.players[1].ally = null;
|
|
g.players[2].ally = 'r';
|
|
const before = handIds(g);
|
|
g = send(reload(g), 'e', { type: 'passResponse' });
|
|
assert.deepEqual(handIds(g), before);
|
|
assert.equal(g.pendingRicheseGift, null);
|
|
assert.equal(g.response, null);
|
|
assert.match(g.log.at(-1)!.text, /became unavailable/);
|
|
});
|
|
void test('gift allowance suspends phase opening and restores its passes plus the original response before any income', () => {
|
|
let g = fixture('guild');
|
|
// Preserve the paused-parent fixture, with the income producer's real phase.
|
|
g.phase = 5;
|
|
hold(g, 'e', 'Karama');
|
|
const parent = {
|
|
kind: 'guildIncome' as const,
|
|
owner: 'e',
|
|
amount: 3,
|
|
passed: ['r'],
|
|
};
|
|
g.response = parent;
|
|
g.phaseOpening = { passed: ['r'], initialize: false };
|
|
g = gift(g);
|
|
assert.equal(g.phaseOpening, null);
|
|
assert.deepEqual(g.pendingRicheseGift!.resume.phaseOpening, {
|
|
passed: ['r'],
|
|
initialize: false,
|
|
});
|
|
g = send(reload(g), 'e', { type: 'passResponse' });
|
|
assert.equal(g.pendingRicheseGift, null);
|
|
assert.deepEqual(g.response, parent);
|
|
assert.deepEqual(g.phaseOpening, { passed: ['r'], initialize: false });
|
|
assert.equal(g.players[2].spice, 20);
|
|
assert.deepEqual(
|
|
g.players[1].hand.map((c) => c.id),
|
|
['richese-karama'],
|
|
);
|
|
g = send(g, 'a', { type: 'ready' });
|
|
g = send(g, 'e', { type: 'ready' });
|
|
assert.equal(g.phaseOpening, null);
|
|
assert.equal(g.response?.kind, 'guildIncome');
|
|
g = send(g, 'a', { type: 'card', mode: 'cancel', card: 'richese-karama' });
|
|
assert.equal(g.players[2].spice, 20);
|
|
assert.equal(g.discard.filter((c) => c.id === 'richese-karama').length, 1);
|
|
assert.equal(g.response, null);
|
|
});
|
|
void test('nested Worthless Karama and its counter preserve the gift reservation and then restore the earlier response', () => {
|
|
let g = fixture('guild');
|
|
// Preserve the paused-parent fixture, with the income producer's real phase.
|
|
g.phase = 5;
|
|
g.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
|
g.order.push('b');
|
|
g.players[3].spice = 20;
|
|
const counter = hold(g, 'e', 'Karama');
|
|
const worthless = hold(g, 'b', 'Baliset');
|
|
const parent = {
|
|
kind: 'guildIncome' as const,
|
|
owner: 'e',
|
|
amount: 3,
|
|
passed: ['r'],
|
|
};
|
|
g.response = parent;
|
|
g = gift(g);
|
|
g = send(g, 'b', { type: 'card', mode: 'cancel', card: worthless });
|
|
assert.equal(g.response?.kind, 'worthlessKarama');
|
|
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
|
assert.equal(
|
|
viewGame(g, 'r').responseControls!.cancelCards.includes('richese-karama'),
|
|
false,
|
|
);
|
|
const before = structuredClone(g);
|
|
assert.throws(
|
|
() =>
|
|
send(g, 'r', { type: 'card', mode: 'cancel', card: 'richese-karama' }),
|
|
/reserved/,
|
|
);
|
|
assert.deepEqual(g, before);
|
|
g = send(reload(g), 'e', { type: 'card', mode: 'cancel', card: counter });
|
|
assert.equal(g.pendingRicheseGift, null);
|
|
assert.equal(g.pendingKarama ?? null, null);
|
|
assert.deepEqual(g.response, parent);
|
|
assert.deepEqual(
|
|
g.players[1].hand.map((c) => c.id),
|
|
['richese-karama'],
|
|
);
|
|
assert.equal(g.discard.filter((c) => c.id === worthless).length, 1);
|
|
assert.equal(g.discard.filter((c) => c.id === counter).length, 1);
|
|
g = send(g, 'a', { type: 'card', mode: 'cancel', card: 'richese-karama' });
|
|
assert.equal(g.players[2].spice, 20);
|
|
assert.equal(g.response, null);
|
|
});
|
|
void test('Harkonnen cannot randomly extract a pending gift and rejection consumes no RNG or cards', (t) => {
|
|
let g = fixture('harkonnen');
|
|
const k = hold(g, 'e', 'Karama');
|
|
hold(g, 'r', 'Shield');
|
|
g = gift(g);
|
|
const before = structuredClone(g);
|
|
let draws = 0;
|
|
t.mock.method(crypto, 'getRandomValues', () => {
|
|
draws++;
|
|
throw new Error('Unexpected RNG');
|
|
});
|
|
assert.throws(
|
|
() =>
|
|
send(g, 'e', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: k,
|
|
target: 'r',
|
|
amount: 1,
|
|
}),
|
|
/reserved Richese gift/,
|
|
);
|
|
assert.equal(draws, 0);
|
|
assert.deepEqual(g, before);
|
|
});
|
|
void test('ordinary winning bids and every sealed submission preserve the recipient final slot without revealing a zero bid', () => {
|
|
const ordinary = fixture();
|
|
ordinary.players[1].hand = ordinary.deck.splice(0, 3);
|
|
ordinary.auction = {
|
|
cards: [ordinary.deck.shift()!],
|
|
index: 0,
|
|
bid: 2,
|
|
bidder: 'a',
|
|
active: 'e',
|
|
passed: [],
|
|
opener: 0,
|
|
};
|
|
const before = structuredClone(ordinary);
|
|
assert.throws(() => gift(ordinary), /committed auction/);
|
|
assert.deepEqual(ordinary, before);
|
|
const offers = [];
|
|
for (const amount of [0, 2]) {
|
|
const g = fixture();
|
|
g.players[1].hand = g.deck.splice(0, 3);
|
|
const card = g.richeseCache![0];
|
|
g.richeseAuction = createRicheseAuction({
|
|
event: 'silent',
|
|
cardId: card.id,
|
|
source: 'cache',
|
|
owner: 'r',
|
|
method: 'silent',
|
|
eligible: ['r', 'a', 'e'],
|
|
order: ['r', 'a', 'e'],
|
|
tieOrder: ['r', 'a', 'e'],
|
|
});
|
|
g.richeseAuction = submitRicheseBid(
|
|
g.richeseAuction,
|
|
{ event: 'silent', actor: 'a', amount },
|
|
20,
|
|
);
|
|
g.richeseFunding = { a: { amount, allyPayment: 0, donor: null } };
|
|
assert.throws(() => gift(g), /sealed submissions/);
|
|
offers.push(viewGame(g, 'r').richeseGift);
|
|
}
|
|
assert.deepEqual(offers[0], offers[1]);
|
|
});
|
|
void test('a gift can fill the eighth Harkonnen slot while its suspended bonus finishes without drawing a ninth card', () => {
|
|
let g = fixture();
|
|
g.players[1].faction = 'harkonnen';
|
|
g.players[1].hand = g.deck.splice(0, 7);
|
|
hold(g, 'e', 'Karama');
|
|
g.auction = {
|
|
cards: [g.players[1].hand[0], g.deck.shift()!],
|
|
index: 0,
|
|
bid: 1,
|
|
bidder: 'a',
|
|
active: 'a',
|
|
passed: [],
|
|
opener: 0,
|
|
};
|
|
g.currentAuctionSale = {
|
|
winner: 'a',
|
|
amount: 1,
|
|
free: false,
|
|
origin: 'normal',
|
|
seller: null,
|
|
};
|
|
g.response = { kind: 'harkonnenBonus', owner: 'a', passed: [] };
|
|
const deck = [...g.deck];
|
|
g = gift(g);
|
|
g = send(reload(g), 'e', { type: 'passResponse' });
|
|
assert.equal(g.players[1].hand.length, 8);
|
|
assert.equal(
|
|
g.players[1].hand.filter((c) => c.id === 'richese-karama').length,
|
|
1,
|
|
);
|
|
assert.deepEqual(g.deck, deck);
|
|
assert.equal(g.pendingRicheseGift, null);
|
|
assert.notEqual(g.response?.kind, 'harkonnenBonus');
|
|
assert.match(
|
|
g.log.map((l) => l.text).join('\n'),
|
|
/reached its eight-card limit/,
|
|
);
|
|
assert.deepEqual(normalizeAutomaticGame(g), g);
|
|
});
|
|
void test('gifting a CHOAM trade proposal card leaves its final confirmation free to decline the now-impossible exchange', () => {
|
|
let g = fixture();
|
|
g.players[1].faction = 'choam';
|
|
const offered = hold(g, 'a', 'Shield');
|
|
hold(g, 'e', 'Karama');
|
|
g.choamMarket = {
|
|
owner: 'a',
|
|
resume: 'phase',
|
|
blocked: [],
|
|
trade: { ally: 'r', offered, returned: 'richese-karama' },
|
|
};
|
|
g.decision = { kind: 'choamTradeConfirm', player: 'a' };
|
|
g = gift(g);
|
|
g = send(g, 'e', { type: 'passResponse' });
|
|
const before = handIds(g);
|
|
assert.equal(g.decision?.kind, 'choamTradeConfirm');
|
|
g = send(reload(g), 'a', { type: 'decision', accept: true });
|
|
assert.deepEqual(handIds(g), before);
|
|
assert.equal(g.choamMarket!.trade, undefined);
|
|
assert.equal(g.decision?.kind, 'choamMarket');
|
|
assert.equal(g.choamTradeTurn, undefined);
|
|
});
|
|
|
|
void test('a real Harkonnen exchange reserves its target return slots while permitting gifts that still leave enough room', () => {
|
|
for (const initialSize of [4, 3]) {
|
|
let g = fixture('harkonnen');
|
|
const karama = hold(g, 'e', 'Karama');
|
|
hold(g, 'e', 'Shield');
|
|
for (const name of ['Maula Pistol', 'Snooper', 'Lasgun', 'Baliset'].slice(
|
|
0,
|
|
initialSize,
|
|
))
|
|
hold(g, 'a', name);
|
|
const originalTarget = g.players[1].hand.map((c) => c.id);
|
|
const inventory = (state: Game) =>
|
|
[...state.players.flatMap((p) => p.hand), ...state.discard]
|
|
.map((c) => c.id)
|
|
.sort();
|
|
const beforeInventory = inventory(g);
|
|
g = send(g, 'e', {
|
|
type: 'card',
|
|
mode: 'special',
|
|
card: karama,
|
|
target: 'a',
|
|
amount: 2,
|
|
});
|
|
assert.deepEqual(g.decision, {
|
|
kind: 'handExchange',
|
|
player: 'e',
|
|
target: 'a',
|
|
count: 2,
|
|
});
|
|
assert.equal(g.players[1].hand.length, initialSize - 2);
|
|
const returned = g.players[2].hand
|
|
.filter((c) => originalTarget.includes(c.id))
|
|
.map((c) => c.id);
|
|
assert.equal(returned.length, 2);
|
|
const exchange = structuredClone(g.decision);
|
|
if (initialSize === 4) {
|
|
const before = structuredClone(g);
|
|
assert.throws(() => gift(g), /Leave room.*committed hand exchange/);
|
|
assert.deepEqual(g, before);
|
|
assert.equal(
|
|
viewGame(g, 'r').richeseGift!.cards.some(
|
|
(c) => c.id === 'richese-karama',
|
|
),
|
|
false,
|
|
);
|
|
} else {
|
|
g = gift(reload(g));
|
|
assert.equal(g.pendingRicheseGift, null);
|
|
assert.deepEqual(g.decision, exchange);
|
|
assert.equal(
|
|
g.players[1].hand.some((c) => c.id === 'richese-karama'),
|
|
true,
|
|
);
|
|
}
|
|
g = send(reload(g), 'e', { type: 'decision', returnCards: returned });
|
|
assert.equal(g.pendingExchange, null);
|
|
assert.notEqual(g.decision?.kind, 'handExchange');
|
|
assert.equal(g.players[1].hand.length, 4);
|
|
assert.equal(g.players[2].hand.length, 1);
|
|
for (const id of originalTarget)
|
|
assert.equal(
|
|
g.players[1].hand.some((c) => c.id === id),
|
|
true,
|
|
);
|
|
assert.equal(g.discard.filter((c) => c.id === karama).length, 1);
|
|
assert.deepEqual(inventory(g), beforeInventory);
|
|
}
|
|
});
|