Files
Dune/tests/emperor-timing-gift-controls.test.tsx
T
Quintine d4abc6bb83
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow Emperor gifts during Basic Ix phase openings
2026-10-29 06:33:41 +11:00

130 lines
7.1 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, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
import { EmperorTimingGift } from '../components/emperor-timing-gift';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
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 { GameTable } = await import('../components/game-table');
aliases.deregister();
/** A public Advanced start and native setup, followed by a staged mutual
* alliance and clean Guild timing boundary, as in the engine journey. */
function fixture(): Game {
let game = createGame('EMPGIFTCONTROL', newPlayer('e','Emperor','emperor'), true);
joinGame(game, newPlayer('g','Guild','guild'));
joinGame(game, newPlayer('h','Harkonnen','harkonnen'));
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
game = applyAction(game, 'e', {type:'start', advancedPreview:true});
for (let step = 0; game.status === 'setup' && step < 60; step++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
game.players.find(player => player.id === 'e')!.ally = 'g';
game.players.find(player => player.id === 'g')!.ally = 'e';
Object.assign(game, {phase:5, turn:1, order:['e','g','h'], active:null,
movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false,
decision:{kind:'guildTiming', player:'g', next:'e', following:'e'},
response:null, phaseOpening:null, ready:[]});
return game;
}
const component = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(EmperorTimingGift, {game, busy, act() {}}));
const table = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
void test('quoted Guild timing gift shows the ally, amount bounds and explanation', () => {
const game = fixture(), view = viewGame(game, 'e');
assert.deepEqual(view.emperorGuildTimingGift, {recipient:'g', maximum:10});
const html = component(view);
assert.match(html, /Share spice with Guild/);
assert.match(html, /Guild timing decision resumes after the Karama response/);
assert.match(html, /Spice to share/);
assert.match(html, /type="number"[^>]*min="1"[^>]*max="10"/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
});
void test('the gift component hides absent quotes and disables submission while busy', () => {
const game = fixture(), own = viewGame(game, 'e'), other = viewGame(game, 'h');
assert.equal(other.emperorGuildTimingGift, null);
assert.equal(component(other), '');
const busy = component(own, true);
assert.match(busy, /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
assert.match(busy, /<input\b[^>]*disabled=""/);
});
void test('the real Guild timing table exposes the gift only to the Emperor', () => {
const game = fixture(), own = viewGame(game, 'e'), guild = viewGame(game, 'g'), observer = viewGame(game, 'h');
assert.equal(game.decision?.kind, 'guildTiming');
assert.match(table(own), /Share spice with Guild/);
assert.match(table(own), /Guild timing decision resumes after the Karama response/);
assert.doesNotMatch(table(guild), /Share spice with Guild/);
assert.doesNotMatch(table(observer), /Share spice with Guild/);
assert.match(table(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
});
void test('an Extortion quote presents the recipient and restores its original decision after Karama', () => {
const game = fixture();
// Isolate the component contract: the underlying Guild fixture supplies the
// seats, while this projected Extortion choice and quote are simulated.
const decision = {kind:'moritaniExtortion' as const, player:'h', event:'extortion-test'};
const own: GameView = {...viewGame(game, 'e'), decision, emperorGuildTimingGift:null,
emperorExtortionGift:{recipient:'h', maximum:4}};
const observer: GameView = {...viewGame(game, 'h'), decision, emperorGuildTimingGift:null,
emperorExtortionGift:null};
const html = component(own);
assert.match(html, /Share spice with Harkonnen/);
assert.match(html, /Moritani Extortion decision resumes after the Karama response/);
assert.match(html, /type="number"[^>]*min="1"[^>]*max="4"/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
assert.equal(component(observer), '');
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/);
assert.doesNotMatch(table(observer), /Share spice with Harkonnen/);
});
void test('a phase-opening quote gives only its owner the gift control', () => {
const game = fixture();
// Exercise the isolated projected contract; the engine owns its eligibility.
const own: GameView = {...viewGame(game, 'e'), emperorGuildTimingGift:null,
emperorExtortionGift:null, emperorPhaseOpeningGift:{recipient:'g', maximum:6}};
const observer: GameView = {...viewGame(game, 'h'), emperorGuildTimingGift:null,
emperorExtortionGift:null, emperorPhaseOpeningGift:null};
const html = component(own);
assert.match(html, /Share spice with Guild/);
assert.match(html, /Phase opening resumes after the Karama response to your gift/);
assert.match(html, /type="number"[^>]*min="1"[^>]*max="6"/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
assert.equal(component(observer), '');
assert.equal(component({...own, emperorPhaseOpeningGift:null}), '');
});
void test('the real Basic Ix opening table exposes the control only to the Emperor', () => {
const {game} = createTruthTurnAmalFixture();
game.players.find(player => player.id === 'emperor')!.ally = 'atreides';
game.players.find(player => player.id === 'atreides')!.ally = 'emperor';
const own = table(viewGame(game, 'emperor'));
const observer = table(viewGame(game, 'harkonnen'));
assert.match(own, /Before Storm/);
assert.match(own, /Phase opening resumes after the Karama response to your gift/);
assert.doesNotMatch(observer, /Phase opening resumes after the Karama response to your gift/);
});