133 lines
7.3 KiB
TypeScript
133 lines
7.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { registerHooks } from 'node:module';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { discoveryStormActions } from '../game/discovery-storm-options';
|
|
import { discoveryStormDistance } from '../game/discovery-storm';
|
|
import { stormSectorAfter } from '../game/disaster-rules';
|
|
import { createTestingStationContestFixture } from './fixture-testing-station-contest';
|
|
|
|
const aliases = registerHooks({
|
|
resolve(specifier, context, next) {
|
|
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
|
},
|
|
load(url, context, next) {
|
|
if (!url.endsWith('.module.css')) return next(url, context);
|
|
const css = readFileSync(new URL(url), 'utf8');
|
|
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
|
|
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
|
},
|
|
});
|
|
const { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho');
|
|
aliases.deregister();
|
|
|
|
const card = 'richese-juice-of-sapho';
|
|
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
|
const originals = new Map<boolean, ReturnType<typeof createTestingStationContestFixture>>();
|
|
function fixture(advanced: boolean, place: 'first' | 'second') {
|
|
let original = originals.get(advanced);
|
|
if (!original) {
|
|
original = createTestingStationContestFixture({advanced});
|
|
originals.set(advanced, original);
|
|
}
|
|
const game = structuredClone(original.game);
|
|
const holder = original.order[place === 'first' ? 0 : 1];
|
|
const target = game.players.find(player => player.id === holder)!;
|
|
// The original fixture has only Ecaz components. Supply its one real printed
|
|
// Sapho face from the Richese cache, retaining a full hand by transferring an
|
|
// existing held card to the deck. This explicitly staged acquisition is not
|
|
// evidence of mixed-expansion roster admission or a natural purchase.
|
|
const sapho = richeseCards().find(candidate => candidate.id === card);
|
|
assert.ok(sapho);
|
|
assert.ok(![...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)].some(candidate => candidate.id === card));
|
|
if (target.hand.length >= 4) game.deck.push(target.hand.pop()!);
|
|
target.hand.push(sapho);
|
|
return {game, holder, original};
|
|
}
|
|
function saphoOption(game: Game, holder: string, mode: 'first' | 'last') {
|
|
const option = viewGame(game, holder).saphoOptions.find(choice => String(choice.scope) === 'testingStation' && choice.mode === mode);
|
|
assert.ok(option, `${holder} has a real ${mode} station opportunity`);
|
|
return option;
|
|
}
|
|
function saphoAction(game: Game, holder: string, mode: 'first' | 'last'): Action {
|
|
const option = saphoOption(game, holder, mode);
|
|
return {type:'card', card, scope:option.scope, event:option.event, mode:option.mode};
|
|
}
|
|
function stationAction(game: Game, actor: string, delta: number) {
|
|
const action = discoveryStormActions(viewGame(game, actor)).find(choice => choice.delta === delta);
|
|
assert.ok(action, `${actor} can make the ${delta} station adjustment`);
|
|
return action;
|
|
}
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
|
|
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) void test(
|
|
`${advanced ? 'Advanced' : 'Basic'} Sapho changes the contested station order ${mode} exactly once`, () => {
|
|
const f = fixture(advanced, mode === 'first' ? 'second' : 'first');
|
|
const initial = f.game, action = saphoAction(initial, f.holder, mode);
|
|
const physicalOrder = [...initial.order], beforeOrder = [...initial.ecologicalStorm!.queue!.order];
|
|
const beforeEvent = initial.ecologicalStorm!.event, sourceDistance = initial.stormPending!;
|
|
const other = beforeOrder.find(id => id !== f.holder)!;
|
|
reject(initial, other, action);
|
|
reject(initial, f.holder, {...action, event:'stale-station-event'});
|
|
let game = applyAction(reload(initial), f.holder, action);
|
|
assert.deepEqual(game.order, physicalOrder);
|
|
assert.deepEqual(game.ecologicalStorm?.queue?.order, [...beforeOrder].reverse());
|
|
assert.notEqual(game.ecologicalStorm?.event, beforeEvent);
|
|
assert.equal(game.ecologicalStorm?.owner, game.ecologicalStorm?.queue?.order[0]);
|
|
assert.equal(game.decision?.player, game.ecologicalStorm?.owner);
|
|
assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1);
|
|
assert.ok(!game.players.find(player => player.id === f.holder)!.hand.some(candidate => candidate.id === card));
|
|
reject(game, f.holder, action);
|
|
const first = game.ecologicalStorm!.owner;
|
|
const firstDelta = sourceDistance < 40 ? 1 : -1;
|
|
game = applyAction(reload(game), first, stationAction(game, first, firstDelta));
|
|
assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance + firstDelta);
|
|
assert.equal(game.stormPending, sourceDistance + firstDelta);
|
|
const second = game.ecologicalStorm!.owner;
|
|
assert.notEqual(first, second);
|
|
game = applyAction(reload(game), second, stationAction(game, second, -firstDelta));
|
|
assert.equal(game.ecologicalStorm?.stage, 'complete');
|
|
assert.equal(discoveryStormDistance(game.ecologicalStorm!), sourceDistance);
|
|
assert.equal(game.storm, stormSectorAfter(initial.storm, sourceDistance));
|
|
assert.equal(game.phase, 1);
|
|
assert.equal(game.discard.filter(candidate => candidate.id === card).length, 1);
|
|
},
|
|
);
|
|
|
|
void test('station Sapho controls render and submit the exact supported action, with busy protection', () => {
|
|
for (const mode of ['first', 'last'] as const) {
|
|
const f = fixture(false, mode === 'first' ? 'second' : 'first');
|
|
const view = viewGame(f.game, f.holder), option = saphoOption(f.game, f.holder, mode);
|
|
const html = renderToStaticMarkup(createElement(JuiceOfSapho, {game:view, busy:false, act() {}}));
|
|
assert.match(html, /Testing Station/i);
|
|
const sent: Action[] = [];
|
|
SaphoOrderButton({card, option, busy:false, act(action) {sent.push(action);}}).props.onClick();
|
|
assert.deepEqual(sent, [saphoAction(f.game, f.holder, mode)]);
|
|
SaphoOrderButton({card, option, busy:true, act(action) {sent.push(action);}}).props.onClick();
|
|
assert.equal(sent.length, 1);
|
|
}
|
|
});
|
|
|
|
void test('all minimal bot profiles have a legal current station choice after Sapho', () => {
|
|
const f = fixture(false, 'second');
|
|
const game = applyAction(f.game, f.holder, saphoAction(f.game, f.holder, 'first'));
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game, f.holder);
|
|
view.players.find(player => player.id === f.holder)!.bot = difficulty;
|
|
const choice = botActions(view).find(action => action.type === 'decision');
|
|
assert.ok(choice, `${difficulty} can choose the original station adjustment`);
|
|
const next = applyAction(game, f.holder, choice);
|
|
assert.equal(next.ecologicalStorm?.queue?.choices.length, 1);
|
|
}
|
|
});
|