Files
Dune/tests/discovery-controls.test.ts

189 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame,
type Game, type GameView,
} from '../game/engine';
import { botActions } from '../game/bots';
import {
placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken,
type DiscoveryTokenFace,
} from '../game/discoveries';
import { discoveryAction, discoveryBotActions, discoveryDiscardAction } from '../game/discovery-options';
import { DiscoveryDiscardDecision, DiscoveryPanel } from '../components/discoveries';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const html = (game: GameView, busy = false) => renderToStaticMarkup(createElement(DiscoveryPanel, { game, busy, act() {} }));
const discardHtml = (game: GameView, busy = false) => renderToStaticMarkup(createElement(DiscoveryDiscardDecision, { game, busy, act() {} }));
/** Genuine setup, then conserved board/card staging for Collection controls. */
function fixture() {
let game = createGame('DISCOVERYCONTROLS', newPlayer('p', 'Atreides', 'atreides'));
joinGame(game, newPlayer('f', 'Fremen', 'fremen'));
joinGame(game, newPlayer('g', 'Guild', 'guild'));
game.discoveryEnabled = true;
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = initializeDiscoveryGameForAudit(game);
for (let count = 0; game.status === 'setup' && count < 50; count++) {
const owner = viewGame(game, 'p').setupPending[0];
assert.ok(owner);
const view = viewGame(game, owner);
view.players.find(player => player.id === owner)!.bot = 'Hard';
const action = botActions(view)[0];
assert.ok(action);
game = applyAction(game, owner, action);
}
assert.equal(game.status, 'playing');
Object.assign(game, { phase: 7, turn: 2, active: null, ready: [], response: null, decision: null, phaseOpening: null });
for (const player of game.players) {
game.deck.push(...player.hand.splice(0));
player.forces = {};
player.reserves = 20;
player.tanks = 0;
}
game.players[0].forces = { 'gara_kulon:8': 1, 'pasty_mesa:7': 1 };
game.players[0].reserves = 18;
return game;
}
function place(game: Game, face: DiscoveryTokenFace, known = false) {
const token = game.discoveries!.tokens.find(token => token.face === face)!;
const pool = game.discoveries!.tokens.filter(candidate => candidate.type === token.type && candidate.status === 'supply');
const index = pool.findIndex(candidate => candidate.id === token.id);
assert.ok(index >= 0);
game.discoveries = placeDiscovery(game.discoveries!,
token.type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-funeral-plain',
() => (index + 0.5) / pool.length);
if (known) game.discoveries = rememberDiscoveryFace(game.discoveries, token.id, 'atreides');
return token.id;
}
void test('unknown backs expose only their type and placement; shared choices bind the quoted opaque token', () => {
const game = fixture(), hidden = place(game, 'spice-stash'), known = place(game, 'cistern', true);
const view = viewGame(game, 'p'), before = structuredClone(view);
assert.deepEqual(discoveryAction(view, hidden, false), { type: 'discovery', token: hidden, reveal: false });
assert.equal(discoveryAction(view, hidden, true), null);
assert.equal(discoveryAction(view, known, false), null);
assert.deepEqual(discoveryAction(view, known, true), { type: 'discovery', token: known, reveal: true });
assert.equal(discoveryAction(view, 'discovery-token-99', false), null);
const markup = html(view);
assert.match(markup, /Smuggler discovery/);
assert.match(markup, /Face down · unknown to you/);
assert.match(markup, /Pasty Mesa · sector 7/);
assert.doesNotMatch(markup, /Spice Stash|Receive seven spice/);
assert.match(markup, /Read Cistern rules/);
assert.match(markup, /Inspect Smuggler token privately/);
assert.match(markup, /Reveal Cistern to everyone/);
assert.deepEqual(discoveryBotActions(view).map(action => action.reveal), [false, true]);
assert.deepEqual(view, before);
});
void test('Fremen and Guild privileged faces remain distinct from another seat’s private inspection', () => {
const game = fixture();
place(game, 'cistern');
place(game, 'spice-stash');
const fremen = viewGame(game, 'f'), guild = viewGame(game, 'g'), atreides = viewGame(game, 'p');
assert.match(html(fremen), /Read Cistern rules/);
assert.doesNotMatch(html(fremen), /Read Spice Stash rules/);
assert.match(html(guild), /Read Spice Stash rules/);
assert.doesNotMatch(html(guild), /Read Cistern rules/);
assert.doesNotMatch(html(atreides), /Read (Cistern|Spice Stash) rules/);
assert.deepEqual(discoveryBotActions(fremen), []);
assert.deepEqual(discoveryBotActions(guild), []);
});
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
let game = fixture();
const token = place(game, 'spice-stash'), before = game.players[0].spice;
for (const reveal of [false, true]) {
const view = viewGame(game, 'p');
view.players[0].bot = difficulty;
const preserved = structuredClone(view), actions = discoveryBotActions(view);
assert.deepEqual(actions, [{ type: 'discovery', token, reveal }]);
for (const action of actions) assert.doesNotThrow(() => applyAction(reload(game), 'p', action));
assert.deepEqual(view, preserved);
game = applyAction(reload(game), 'p', actions[0]);
if (!reveal) {
assert.equal(game.players[0].spice, before);
assert.doesNotMatch(html(viewGame(game, 'f')), /Read Spice Stash rules/);
}
}
assert.equal(game.players[0].spice, before + 7);
assert.equal(game.discoveries!.tokens.find(candidate => candidate.id === token)!.status, 'removed');
assert.deepEqual(discoveryBotActions(viewGame(game, 'p')), []);
assert.throws(() => applyAction(game, 'p', { type: 'discovery', token, reveal: true }));
}
});
void test('carried and resolved tokens show public custody without a repeated reveal action', () => {
const game = fixture(), carried = place(game, 'ornithopter'), removed = place(game, 'spice-stash');
game.discoveries = revealDiscoveryToken(game.discoveries!, carried, game.turn, 'p').state;
game.discoveries = revealDiscoveryToken(game.discoveries, removed, game.turn, 'p').state;
assert.match(html(viewGame(game, 'p')), /Carried by you/);
const observer = html(viewGame(game, 'f'));
assert.match(observer, /Carried by Atreides/);
assert.match(observer, /Resolved and removed/);
assert.doesNotMatch(observer, /Reveal Ornithopter to everyone|Reveal Spice Stash to everyone/);
});
void test('a full-hand stash offers every owned card after its real draw, with private inspection and Worthless-first AI', () => {
let game = fixture();
const stash = place(game, 'treachery-card-stash', true), owner = game.players[0];
for (const kind of ['shield', 'projectile', 'poison', 'worthless']) {
const index = game.deck.findIndex(card => card.kind === kind);
assert.ok(index >= 0);
owner.hand.push(game.deck.splice(index, 1)[0]);
}
const newCard = game.deck[0].id, worthless = owner.hand.find(card => card.kind === 'worthless')!.id;
game = applyAction(game, 'p', discoveryAction(viewGame(game, 'p'), stash, true)!);
assert.equal(game.decision?.kind, 'discoveryDiscard');
assert.equal(game.players[0].hand.length, 5);
const view = viewGame(game, 'p'), actions = discoveryBotActions(view), before = structuredClone(view);
assert.equal(actions.length, 5);
assert.equal(actions[0].card, worthless);
assert.equal(discoveryDiscardAction(view, 'not-owned'), null);
assert.ok(discoveryDiscardAction(view, newCard));
for (const action of actions) {
const next = applyAction(reload(game), 'p', action);
assert.equal(next.players[0].hand.length, 4);
assert.equal(next.discard.filter(card => card.id === action.card).length, 1);
assert.equal(next.discoveryStash?.stage, 'complete');
assert.equal(discoveryDiscardAction(viewGame(next, 'p'), String(action.card)), null);
}
const markup = discardHtml(view);
for (const card of game.players[0].hand) assert.ok(markup.includes(card.name), card.name);
assert.match(markup, /Card to discard/);
assert.match(markup, /Inspect card/);
assert.match(markup, /new card is already in your hand/);
const observer = viewGame(game, 'f');
assert.equal(discardHtml(observer), '');
assert.deepEqual(discoveryBotActions(observer), []);
assert.match(discardHtml(view, true).match(/<select[^>]*>/)![0], /disabled=""/);
const autopilot = structuredClone(view); autopilot.players[0].autopilot = 'Hard';
assert.match(discardHtml(autopilot).match(/<select[^>]*>/)![0], /disabled=""/);
assert.deepEqual(view, before);
});
void test('quoted blocking and human busy or autopilot controls retain readable tokens without offering actions', () => {
const game = fixture(), token = place(game, 'cistern', true), view = viewGame(game, 'p');
for (const busy of [true, false]) {
const locked = structuredClone(view);
if (!busy) locked.players[0].autopilot = 'Medium';
const button = html(locked, busy).match(/<button[^>]*>Reveal Cistern to everyone<\/button>/)![0];
assert.match(button, /disabled=""/);
assert.match(html(locked, busy), /Read Cistern rules/);
}
const blocked = structuredClone(view);
blocked.discoveries!.blocked = 'Finish the current interaction first.';
assert.equal(discoveryAction(blocked, token, true), null);
assert.deepEqual(discoveryBotActions(blocked), []);
assert.match(html(blocked), /Finish the current interaction first/);
assert.doesNotMatch(html(blocked), /Reveal Cistern to everyone/);
const later = structuredClone(view); later.phase = 8;
assert.equal(discoveryAction(later, token, true), null);
assert.deepEqual(discoveryBotActions(later), []);
});