145 lines
6.9 KiB
TypeScript
145 lines
6.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { registerHooks } from 'node:module';
|
|
import { bribeAction } from '../game/bribe-options';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
|
import { bureaucratPaymentGame } from './bureaucrat-payment-fixture';
|
|
|
|
const aliases = registerHooks({ resolve(specifier, context, next) {
|
|
return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
|
} });
|
|
const { Bribes } = await import('../components/bribes');
|
|
aliases.deregister();
|
|
|
|
function game() {
|
|
let g = createGame('BRIBES22', newPlayer('a', 'Atreides payer', 'atreides'));
|
|
joinGame(g, newPlayer('h', 'Harkonnen recipient', 'harkonnen'));
|
|
joinGame(g, newPlayer('e', 'Emperor observer', 'emperor'));
|
|
g.players.forEach(p => { p.ready = true; });
|
|
g = applyAction(g, 'a', { type: 'start' });
|
|
for (const p of g.players)
|
|
if (p.traitorChoices.length) g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
|
assert.equal(g.status, 'playing');
|
|
return g;
|
|
}
|
|
function options(g: Game, id = 'a') { return viewGame(g, id).bribeOptions; }
|
|
function reject(g: Game, actor: string, action: object) {
|
|
const before = JSON.stringify(g);
|
|
assert.throws(() => applyAction(g, actor, action as Parameters<typeof applyAction>[2]));
|
|
assert.equal(JSON.stringify(g), before);
|
|
}
|
|
|
|
void test('out-of-turn bribes collect at a controlled Mentat opening and late payments remain deferred after JSON restoration', () => {
|
|
let g = game();
|
|
g.active = 'e';
|
|
const before = JSON.stringify(g);
|
|
const quote = options(g);
|
|
assert.equal(quote.blocked, null);
|
|
assert.equal(quote.available, 10);
|
|
assert.deepEqual(quote.targets.map(p => p.id), ['h', 'e']);
|
|
assert.equal(JSON.stringify(g), before);
|
|
const action = bribeAction(quote, 'h', 4)!;
|
|
assert.deepEqual(action, { type: 'bribe', target: 'h', amount: 4 });
|
|
g = JSON.parse(JSON.stringify(applyAction(g, 'a', action)));
|
|
assert.equal(g.players[0].spice, 6);
|
|
assert.equal(g.players[1].spice, 10);
|
|
assert.equal(options(g, 'h').incoming, 4);
|
|
assert.equal(options(g, 'h').available, 10);
|
|
assert.equal(options(g, 'e').incoming, 0);
|
|
assert.equal(viewGame(g, 'e').players[1].bribes, undefined);
|
|
assert.match(g.log.at(-1)!.text, /paid a 4-spice bribe/);
|
|
g.phase = 7; // Controlled later collection boundary, not a full-game sample.
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
assert.equal(g.phase, 8);
|
|
assert.equal(g.players[1].spice, 14);
|
|
assert.equal(options(g, 'h').incoming, 0);
|
|
assert.equal(options(g).blocked, null);
|
|
assert.deepEqual(options(JSON.parse(JSON.stringify(g)), 'h'), options(g, 'h'));
|
|
g = applyAction(g, 'a', bribeAction(options(g), 'h', 2)!);
|
|
assert.equal(g.phase, 8);
|
|
assert.equal(g.players[0].spice, 4);
|
|
assert.equal(g.players[1].spice, 14);
|
|
assert.equal(options(g, 'h').incoming, 2);
|
|
assert.equal(options(g, 'h').available, 14);
|
|
});
|
|
|
|
void test('invalid amounts, self/allied recipients and stale client options never authorize a payment', () => {
|
|
const g = game();
|
|
g.players[0].ally = 'h'; g.players[1].ally = 'a';
|
|
const quote = options(g);
|
|
assert.deepEqual(quote.targets.map(p => p.id), ['e']);
|
|
for (const target of ['a', 'h', 'missing']) {
|
|
assert.equal(bribeAction(quote, target, 1), null);
|
|
reject(g, 'a', { type: 'bribe', target, amount: 1 });
|
|
}
|
|
for (const amount of [0, -1, 1.5, 11, NaN, Infinity]) {
|
|
assert.equal(bribeAction(quote, 'e', amount), null);
|
|
reject(g, 'a', { type: 'bribe', target: 'e', amount });
|
|
}
|
|
for (const paused of [
|
|
{ inflation: { side: 'double', turn: 2 } },
|
|
{ phaseOpening: { passed: [], initialize: false } },
|
|
{ response: { kind: 'emperorGift', owner: 'e', recipient: 'h', amount: 1, passed: [] } },
|
|
{ truthtrance: { stage: 'priority', queue: [], passed: [], question: null } },
|
|
]) {
|
|
const waiting = Object.assign(structuredClone(g), paused) as Game;
|
|
assert.ok(options(waiting).blocked);
|
|
assert.equal(bribeAction(options(waiting), 'e', 1), null);
|
|
reject(waiting, 'a', { type: 'bribe', target: 'e', amount: 1 });
|
|
}
|
|
});
|
|
|
|
void test('own auction commitment limits bribes without revealing a rivals supply', () => {
|
|
const g = game();
|
|
g.phase = 3;
|
|
const card = g.deck.pop()!;
|
|
g.auction = { cards: [card], index: 0, bid: 7, bidder: 'a', allyPayment: 0, active: 'e', opener: 0, passed: [] };
|
|
assert.equal(options(g).available, 3);
|
|
const other = structuredClone(g);
|
|
other.players[1].spice = 999; other.players[1].bribes = 88;
|
|
assert.deepEqual(options(other), options(g));
|
|
assert.equal(options(applyAction(g, 'a', { type: 'bribe', target: 'h', amount: 3 })).available, 0);
|
|
reject(g, 'a', { type: 'bribe', target: 'h', amount: 4 });
|
|
});
|
|
|
|
void test('Bureaucrat eligibility stays recipient-specific and a saved payment offers its existing decision', () => {
|
|
const g = bureaucratPaymentGame();
|
|
const paid = applyAction(g, 'p', bribeAction(options(g, 'p'), 'e', 5)!);
|
|
assert.equal(paid.decision?.kind, 'bureaucratPayment');
|
|
assert.ok(options(paid, 'p').blocked);
|
|
const settled = applyAction(JSON.parse(JSON.stringify(paid)), 'b', {
|
|
type: 'decision', event: paid.bureaucratPaymentEvent, redirect: true,
|
|
});
|
|
assert.equal(options(settled, 'e').incoming, 3);
|
|
assert.equal(options(settled, 'p').available, 25);
|
|
// The intentional composition change admits the selected ix deck alongside
|
|
// Skills, so the same payment stays available and offers its decision there.
|
|
const combined = structuredClone(g);
|
|
combined.expansions.push('ix');
|
|
const combinedQuote = options(combined, 'p');
|
|
assert.equal(combinedQuote.blocked, null);
|
|
const combinedPaid = applyAction(combined, 'p', bribeAction(combinedQuote, 'e', 5)!);
|
|
assert.equal(combinedPaid.decision?.kind, 'bureaucratPayment');
|
|
});
|
|
|
|
void test('bribe controls render recipient choices, private escrow and disabled unselected or busy payment', () => {
|
|
const g = game(); g.players[0].bribes = 4;
|
|
const render = (state: Game, busy = false) => renderToStaticMarkup(createElement(Bribes, { game: viewGame(state, 'a'), busy, act() {} }));
|
|
const html = render(g);
|
|
assert.match(html, /4 spice awaiting Mentat/);
|
|
assert.match(html, /Bribe recipient/);
|
|
assert.match(html, /Harkonnen recipient/);
|
|
assert.match(html, /collects their share at the start of Mentat Pause/);
|
|
assert.match(html, /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/);
|
|
assert.match(html, /disabled=""[^>]*>Pay bribe/);
|
|
assert.match(html, /rules\?topic=bribes#bribes/);
|
|
assert.ok((render(g, true).match(/disabled=""/g) ?? []).length >= 3);
|
|
g.phase = 8;
|
|
assert.equal(options(g).blocked, null);
|
|
assert.match(render(g), /Payments made during Mentat Pause wait until the start of next turn's Mentat Pause/);
|
|
g.status = 'lobby';
|
|
assert.equal(render(g), '');
|
|
});
|