286 lines
9.2 KiB
TypeScript
286 lines
9.2 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
type GameView,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { baseDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import type { RicheseAuctionMethod } from '../game/richese-auction';
|
|
|
|
type Difficulty = (typeof DIFFICULTIES)[number];
|
|
function fixture(advanced = false) {
|
|
const g = createGame('RICHESEBOTS', newPlayer('r', 'Richese', 'richese'));
|
|
g.players.push(
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('f', 'Fremen', 'fremen'),
|
|
);
|
|
g.status = 'playing';
|
|
g.phase = 2;
|
|
g.turn = 2;
|
|
g.advanced = advanced;
|
|
g.order = ['e', 'f', 'r'];
|
|
g.active = null;
|
|
g.ready = [];
|
|
g.deck = baseDeck();
|
|
g.richeseCache = richeseCards();
|
|
for (const p of g.players) {
|
|
p.hand = [];
|
|
p.spice = 12;
|
|
p.bot = undefined;
|
|
p.forces = {};
|
|
p.traitors = [];
|
|
}
|
|
return g;
|
|
}
|
|
function begin(g: Game) {
|
|
for (const id of g.order) g = applyAction(g, id, { type: 'ready' });
|
|
assert.equal(g.phase, 3);
|
|
return g;
|
|
}
|
|
function projected(g: Game, id: string, difficulty: Difficulty) {
|
|
const view = viewGame(g, id);
|
|
view.players.find((p) => p.id === id)!.bot = difficulty;
|
|
return view;
|
|
}
|
|
function drive(
|
|
g: Game,
|
|
difficulty: Difficulty,
|
|
until: (g: Game) => boolean,
|
|
limit = 100,
|
|
) {
|
|
for (let i = 0; !until(g) && i < limit; i++) {
|
|
let moved = false;
|
|
for (const p of g.players) {
|
|
const before = structuredClone(g);
|
|
const actions = botActions(projected(g, p.id, difficulty));
|
|
assert.deepEqual(g, before, 'policy is read-only');
|
|
if (!actions.length) continue;
|
|
g = applyAction(g, p.id, actions[0]);
|
|
moved = true;
|
|
break;
|
|
}
|
|
assert.ok(
|
|
moved,
|
|
`${difficulty}: no legal continuation at ${g.decision?.kind ?? g.richeseAuction?.method ?? g.phase}`,
|
|
);
|
|
g = JSON.parse(JSON.stringify(g)) as Game;
|
|
}
|
|
assert.ok(until(g), `${difficulty}: finite completion`);
|
|
return g;
|
|
}
|
|
function offer(
|
|
method: RicheseAuctionMethod,
|
|
source: 'cache' | 'blackMarket',
|
|
advanced = false,
|
|
) {
|
|
let g = fixture(advanced);
|
|
if (source === 'blackMarket')
|
|
g.players[0].hand.push(
|
|
g.deck.splice(
|
|
g.deck.findIndex((c) => c.kind === 'shield'),
|
|
1,
|
|
)[0],
|
|
);
|
|
g = begin(g);
|
|
if (source === 'cache') {
|
|
if (g.decision?.kind === 'richeseBlackMarket')
|
|
g = applyAction(g, 'r', {
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
decline: true,
|
|
});
|
|
g = applyAction(g, 'r', {
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
position: 'first',
|
|
});
|
|
}
|
|
g = applyAction(g, 'r', {
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
card: source === 'cache' ? g.richeseCache![0].id : g.players[0].hand[0].id,
|
|
method,
|
|
direction: 'clockwise',
|
|
claim: 'A useful card',
|
|
});
|
|
assert.ok(g.richeseAuction);
|
|
return g;
|
|
}
|
|
|
|
void test('all four profiles complete first/last cache auctions and ordinary lots through the real phase transition', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const initial = begin(fixture());
|
|
const other = botActions(projected(initial, 'e', difficulty));
|
|
assert.deepEqual(other, [], 'nonowner cannot decide cache position');
|
|
const done = drive(initial, difficulty, (g) => g.phase === 4);
|
|
assert.equal(done.richeseBidding!.stage, 'complete');
|
|
assert.equal(done.richeseCache!.length, 9);
|
|
assert.equal(done.richeseAuction, null);
|
|
const identities = [
|
|
...done.deck,
|
|
...done.discard,
|
|
...done.players.flatMap((p) => p.hand),
|
|
...done.richeseCache!,
|
|
...(done.richeseRemoved ?? []),
|
|
];
|
|
assert.equal(identities.length, 43);
|
|
assert.equal(new Set(identities.map((c) => c.id)).size, 43);
|
|
}
|
|
});
|
|
|
|
void test('all profiles skip optional Black Market and legally complete its normal, Once Around and Silent lots when already offered', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let optional = fixture(true);
|
|
optional.players[0].hand.push(optional.deck.shift()!);
|
|
optional = begin(optional);
|
|
const skip = botActions(projected(optional, 'r', difficulty))[0];
|
|
assert.deepEqual(skip, {
|
|
type: 'decision',
|
|
event: optional.richeseBidding!.event,
|
|
decline: true,
|
|
});
|
|
assert.equal(
|
|
applyAction(optional, 'r', skip).decision?.kind,
|
|
'richeseDeclaration',
|
|
);
|
|
for (const method of ['normal', 'onceAround', 'silent'] as const) {
|
|
const g = offer(method, 'blackMarket', true);
|
|
const event = g.richeseAuction!.event;
|
|
const done = drive(
|
|
g,
|
|
difficulty,
|
|
(current) => current.richeseAuction?.event !== event,
|
|
);
|
|
assert.equal(done.decision?.kind, 'richeseDeclaration');
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
void test('unfunded bidders pass, sealed bidders act once, and hidden opponent state cannot affect the bid', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = offer('silent', 'cache');
|
|
g.players.find((p) => p.id === 'e')!.spice = 0;
|
|
const zero = botActions(projected(g, 'e', difficulty))[0];
|
|
assert.deepEqual(zero, {
|
|
type: 'richeseBid',
|
|
event: g.richeseAuction!.event,
|
|
amount: 0,
|
|
allyPayment: 0,
|
|
});
|
|
g = applyAction(g, 'e', zero);
|
|
assert.deepEqual(botActions(projected(g, 'e', difficulty)), []);
|
|
const view = projected(g, 'f', difficulty);
|
|
assert.equal(view.richeseAuction!.ownBid, null);
|
|
assert.equal(view.richeseAuction!.revealedBids, null);
|
|
assert.equal('sealed' in view.richeseAuction!, false);
|
|
const changed = structuredClone(view) as GameView;
|
|
for (const p of changed.players.filter((p) => p.id !== changed.me)) {
|
|
p.spice = 999;
|
|
p.hand = [{ id: 'unentitled', name: 'Secret', kind: 'lasgun' }];
|
|
}
|
|
assert.deepEqual(botActions(changed), botActions(view));
|
|
}
|
|
});
|
|
|
|
void test('projected funding produces exact affordable splits without inspecting ally balances', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = offer('silent', 'cache');
|
|
const view = projected(g, 'e', difficulty);
|
|
view.richeseAuction!.ownAvailable = 0;
|
|
view.richeseAuction!.allyAvailable = 3;
|
|
const action = botActions(view)[0];
|
|
assert.equal(action.type, 'richeseBid');
|
|
assert.ok(
|
|
typeof action.amount === 'number' &&
|
|
action.amount > 0 &&
|
|
action.amount <= 3,
|
|
);
|
|
assert.equal(action.allyPayment, action.amount);
|
|
view.richeseAuction!.allyAvailable = 0;
|
|
assert.equal(botActions(view)[0].amount, 0);
|
|
}
|
|
});
|
|
|
|
void test('ally-funded bot bids are accepted by authoritative escrow validation', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const g = offer('silent', 'cache');
|
|
const bidder = g.players.find((p) => p.id === 'e')!;
|
|
const donor = g.players.find((p) => p.id === 'f')!;
|
|
bidder.ally = donor.id;
|
|
donor.ally = bidder.id;
|
|
bidder.spice = 0;
|
|
donor.spice -= 3;
|
|
g.aid[donor.id] = { recipient: bidder.id, amount: 3 };
|
|
const action = botActions(projected(g, bidder.id, difficulty))[0];
|
|
assert.equal(action.allyPayment, action.amount);
|
|
assert.ok(
|
|
typeof action.amount === 'number' &&
|
|
action.amount > 0 &&
|
|
action.amount <= 3,
|
|
);
|
|
const next = applyAction(g, bidder.id, action);
|
|
assert.equal(next.richeseFunding![bidder.id].allyPayment, action.amount);
|
|
assert.equal(next.richeseFunding![bidder.id].donor, donor.id);
|
|
assert.equal(next.players.find((p) => p.id === bidder.id)!.spice, 0);
|
|
}
|
|
});
|
|
|
|
void test('a pending cancellation window takes priority over Richese auction controls for every profile', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = fixture();
|
|
const other = g.players.find((p) => p.id === 'e')!;
|
|
other.hand.push(
|
|
g.deck.splice(
|
|
g.deck.findIndex((card) => card.effect === 'karama'),
|
|
1,
|
|
)[0],
|
|
);
|
|
g = begin(g);
|
|
g = applyAction(g, 'r', {
|
|
type: 'decision',
|
|
event: g.richeseBidding!.event,
|
|
position: 'first',
|
|
});
|
|
assert.equal(g.response?.kind, 'richeseAuction');
|
|
const actions = botActions(projected(g, 'e', difficulty));
|
|
assert.ok(actions.length > 0);
|
|
assert.ok(
|
|
actions.every(
|
|
(action) => action.type !== 'richeseBid' && action.type !== 'decision',
|
|
),
|
|
);
|
|
assert.doesNotThrow(() => applyAction(g, 'e', actions[0]));
|
|
assert.deepEqual(botActions(projected(g, 'r', difficulty)), []);
|
|
}
|
|
});
|
|
|
|
void test('unbid cache decisions keep a free card only when there is room', () => {
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = offer('silent', 'cache');
|
|
for (const id of g.richeseAuction!.eligible)
|
|
g = applyAction(g, id, {
|
|
type: 'richeseBid',
|
|
event: g.richeseAuction!.event,
|
|
amount: 0,
|
|
allyPayment: 0,
|
|
});
|
|
assert.equal(g.decision?.kind, 'richeseUnbid');
|
|
let action = botActions(projected(g, 'r', difficulty))[0];
|
|
assert.equal(action.keep, true);
|
|
assert.doesNotThrow(() => applyAction(g, 'r', action));
|
|
g.players[0].hand = g.deck.splice(0, 4);
|
|
action = botActions(projected(g, 'r', difficulty))[0];
|
|
assert.equal(action.keep, false);
|
|
const done = applyAction(g, 'r', action);
|
|
assert.equal(done.richeseRemoved!.length, 1);
|
|
}
|
|
});
|