Files
Dune/tests/discovery-controls.test.ts
T
Quintine f0d3d9c464 Prototype Jacurutu income, Testing Station and Shrine effects
Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
2026-09-13 14:50:09 +10:00

203 lines
11 KiB
TypeScript
Raw 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 {
DISCOVERY_TOKENS, 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 eight authorized faces have readable rules while shared location rules preserve parent-territory meaning', () => {
const game = fixture();
for (const definition of DISCOVERY_TOKENS) place(game, definition.id, true);
const markup = html(viewGame(game, 'p'));
for (const definition of DISCOVERY_TOKENS) assert.ok(markup.includes(`Read ${definition.name} rules`), definition.name);
for (const text of ['opposing undialed force', 'two spice from the bank', 'Weather Control',
'use a Truthtrance card as Karama', 'each spice blow that is collected', 'including the new card',
'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions',
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
assert.doesNotMatch(markup, /href="https?:/);
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
assert.ok(markup.includes('Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.'));
});
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), []);
});