Files
Dune/tests/discovery-controls.test.ts
T
Quintine ab0ab52849 Prototype Discovery free entry, Cistern income and carried flight
Connect next-turn optional entry before storm/HMS movement, with typed
multi-sector groups, normal source-storm/occupancy checks, legal bot and human
choices, signed saved arrivals and Bene Gesserit reaction continuation.
Cistern pays its sole occupant two bank spice through canonical Collection.
A carried Ornithopter grants fixed range three on a later-turn actual movement,
preserves pending responses and independent Treachery/No-Field custody, and is
removed only after the selected group moves.

Independent reviews bind pending entry destination baselines, typed custody and
exact reaction continuations. Browser QA caught and fixed an incorrect decision
heading. Orgiz remains disabled pending the stacked-Blow ruling; Jacurutu battle
income, Ecological Testing Station and Shrine remain missing. Mode and publication
gates stay closed; this checkpoint does not certify full Discovery compliance.

Verification: types/lint and 4,313 offline tests passed (49 added), production
build passed, and 40 HTTP tests passed. Final stable run durations: check
139145ms, build 6530ms, integration 24421ms. An earlier run found one stale
Discovery notice assertion, now updated; the next run was interrupted by a
user-confirmed power outage. No interrupted result is claimed as success.

Targeted browser room CA782FQE used genuine human Atreides and two-AI setup,
then physical QA positioning for partial free entry and a three-territory
Ornithopter move. Refresh preserved its private hand, forces, spice and token
removal. The power outage stopped both server and checks; the absent server was
restarted after a private backup. The same browser seat restored correctly and
all 157 pre-restart rooms were unchanged. Final preservation: all 156 opening
rooms unchanged, 31 added QA/integration rooms, total 187. No database reset.

Source fingerprint:
7b639d7f6559a88b00f3cbf913cfa7e6c400bfabb8c392e7b732e8ea77f6dcbc
Private report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-entry-flight/verification-recovered/report.json
Aggregate goal counter 61,819,586 -> 63,110,032 at completed verification:
1,290,446 tokens before final Git overhead for three connected functions,
including helper coordination, reviews, browser QA and interrupted checks.
2026-09-13 14:13:40 +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('Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.'));
});
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), []);
});