Files
Dune/tests/truth-leader-revival-controls.test.tsx
T

207 lines
16 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 assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from '../components/leader-revival-claim-fields';
import { LeaderRevivalPromises, LeaderRevivalPromiseGuideButton } from '../components/leader-revival-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { LeaderRevivalClaim } from '../game/leader-revival-promises';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import { createTechTokens } from '../game/tech-tokens';
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
type Props = Parameters<typeof LeaderRevivalPromiseGuideButton>[0];
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function choose(props: Parameters<typeof LeaderRevivalClaimFields>[0], value: string) {
const select = elements(LeaderRevivalClaimFields(props)).find(element => element.props.id === 'truth-leader-revival-leader');
assert.ok(select);
(select.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function guide(props: Props) { const button = LeaderRevivalPromiseGuideButton(props); assert.ok(button); return button; }
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(LeaderRevivalPromises, { game, busy: false, send() {} }));
/** Controlled personalized projections test UI guards and exact callbacks.
* These are not native acquisition, casualty, eligibility or payment evidence. */
function projected(phase = 4, type = 'reviveLeader', advanced = false): GameView {
const source = createGame('TRUTHLEADERREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.ready = [];
game.allLeaders = [{ id: 'named-leader', name: 'Named Commander', faction: 'emperor', strength: 3 }];
game.leaderRevivalPromises = [{ event: 'controlled-leader-revival-event', player: 'p', asker: 'a',
turn: 2, leader: type === 'reviveKwisatz' ? 'kwisatz' : 'named-leader', answer: type !== 'ready' }];
game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null;
game.active = advanced ? 'p' : null;
game.auction = phase === 3 ? { bid: 4, bidder: type === 'decision' ? 'p' : 'a', active: type === 'decision' ? 'a' : 'p',
remaining: 2, inspectionBlocked: null, card: null } : null;
game.decision = type === 'decision' ? { kind: 'auctionPayment', player: 'p' } : null;
if (type === 'acceptLeaderRevival') game.revivalRequests.p = { leader: 'named-leader', price: 3 };
game.leaderRevivalPromiseGuide = { phase, action: { type, leaderRevivalPromiseEvent: 'controlled-leader-revival-event',
...(type === 'reviveLeader' ? { leader: 'named-leader' } : {}),
...(type === 'revive' ? { amount: 1, elite: 0, freeElite: 0 } : {}),
...(type === 'acceptLeaderRevival' ? { leaderRevivalPrice: game.revivalRequests.p.price } : {}),
...(type === 'decision' ? { karama: true, card: 'projected-original-karama' } : {}) } };
return game;
}
function nativeProjected(method: 'silent' | 'onceAround') {
const game = projected(3, 'richeseBid');
const native = createRicheseAuction({ event: 'controlled-native-auction-event', cardId: 'controlled-card', owner: 'a',
method, source: 'cache', eligible: ['p', 'a'], order: ['p', 'a'], tieOrder: ['p', 'a'] });
game.richeseAuction = { ...projectRicheseAuction(native, 'p'), card: null, claim: null,
ownAllyPayment: 0, ownAvailable: 4, allyAvailable: 0, peekKnown: false, inspectionBlocked: null };
game.leaderRevivalPromiseGuide = { phase: 3, action: { type: 'richeseBid', event: native.event,
amount: method === 'silent' ? 0 : null, allyPayment: 0, leaderRevivalPromiseEvent: 'controlled-leader-revival-event' } };
return game;
}
void test('named-leader fields preserve the fixed turn and emit only parser-valid current name choices', () => {
const value: LeaderRevivalClaim = { turn: 2, leader: 'named-leader' }, changes: LeaderRevivalClaim[] = [];
const leaders = [{ id: 'named-leader', name: 'Named Commander' }, { id: 'kwisatz', name: 'Kwisatz Haderach' }];
const props = { value, leaders, onChange(next: LeaderRevivalClaim) { changes.push(next); } };
const html = renderToStaticMarkup(createElement(LeaderRevivalClaimFields, props));
assert.match(html, /Turn 2/); assert.match(html, /Named Commander/); assert.match(html, /Kwisatz Haderach/);
assert.match(html, /A request alone is not a return/); assert.match(html, /Native revival cycles, costs and turn order still apply/);
assert.equal(elements(LeaderRevivalClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
choose(props, 'kwisatz'); assert.deepEqual(changes, [{ turn: 2, leader: 'kwisatz' }]);
for (const value of ['', 'missing', ' named-leader ']) choose(props, value);
choose({ ...props, disabled: true }, 'kwisatz'); choose({ ...props, leaders: [] }, 'kwisatz');
choose({ ...props, value: { ...props.value, turn: 0 } }, 'kwisatz'); assert.equal(changes.length, 1);
for (const value of [null, {}, { turn: 0, leader: 'named-leader' }, { turn: 2, leader: '' }]) assert.ok(leaderRevivalClaimInputError(value));
assert.ok(leaderRevivalClaimInputError(value, []));
assert.match(renderToStaticMarkup(createElement(LeaderRevivalClaimFields, { ...props, leaders: [] })), /role="alert"/);
});
void test('own leader promises persist with public names, actual-return meaning, statuses and earlier-phase waiting', () => {
const game = projected(); game.leaderRevivalPromiseGuide = null;
game.leaderRevivalPromises.push(
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, leader: 'kwisatz', answer: false },
{ event: 'completed-event', player: 'p', asker: 'a', turn: 1, leader: 'named-leader', answer: true, fulfilled: true },
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, leader: 'missing', answer: true, released: true },
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, leader: 'foreign-hidden-leader-id', answer: true },
);
const before = structuredClone(game);
for (let render = 0; render < 2; render++) {
const html = markup(game);
assert.match(html, /Your leader Revival promises/); assert.match(html, /Named Commander/); assert.match(html, /Kwisatz Haderach/);
assert.match(html, /Yes requires the named leader’s future actual ordinary native return/);
assert.match(html, /No forbids that named ordinary return until your Revival opportunity ends this turn/);
assert.match(html, /A request alone is not a return/); assert.match(html, /Prior returns, Ghola, foreign leaders and special card returns do not count/);
assert.match(html, /Original revival cycles, costs, limits and turn order still apply/);
assert.match(html, /accept an existing, current Tleilaxu quote/); assert.match(html, /does not arrange a new agreement or promise future consent/);
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
assert.match(html, /href="\/rules#truthtrance-leader-revival"/);
assert.doesNotMatch(html, /foreign-hidden-leader-id|controlled-leader-revival-event|released-event/);
}
assert.deepEqual(game, before);
for (const phase of [0, 1, 2, 3]) {
game.phase = phase;
assert.match(markup(game), /Your leader Revival opportunity is still ahead/);
assert.equal(LeaderRevivalPromiseGuideButton({ game, busy: false, send() {} }), null);
}
game.phase = 4; game.revivalOrder = { version: 1, turn: 2, original: ['a', 'p'], remaining: ['a', 'p'], completed: [], started: false };
assert.match(markup(game), /this promise remains visible while the current actor finishes/);
assert.equal(LeaderRevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('actual Basic and ordered Advanced guide callbacks preserve named leader, KH, priced negotiation, joint-force and Ready packets', () => {
for (const advanced of [false, true]) for (const type of ['reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'revive', 'ready']) {
const game = projected(4, type, advanced), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]); assert.equal(sent[0], game.leaderRevivalPromiseGuide!.action);
assert.match(markup(game), type === 'reviveLeader' ? /Revive Named Commander while honoring your answers/
: type === 'reviveKwisatz' ? /Revive Kwisatz Haderach while honoring your answers/
: type === 'acceptLeaderRevival' ? /Accept the current quoted leader revival while honoring your answers/
: type === 'revive' ? /Revive 1 force to prepare your leader Revival/ : /Finish Revival while honoring your named-leader answers/);
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
if (type === 'acceptLeaderRevival') {
assert.deepEqual(sent[0], { type: 'acceptLeaderRevival', leaderRevivalPromiseEvent: 'controlled-leader-revival-event', leaderRevivalPrice: 3 });
game.revivalRequests.p.price = 4; press(button); assert.equal(sent.length, 1);
game.revivalRequests.p.price = 3; game.revivalRequests.p.declined = true; press(button); assert.equal(sent.length, 1);
delete game.revivalRequests.p; press(button); assert.equal(sent.length, 1);
}
}
});
void test('actual preparation callbacks preserve Charity, owned ordinary payment, and current Richese zero or pass packets', () => {
for (const [phase, type] of [[2, 'charity'], [2, 'ready'], [3, 'passBid'], [3, 'decision']] as const) {
const game = projected(phase, type), sent: Action[] = [];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]);
if (type === 'decision') {
assert.equal(game.auction!.active, 'a'); assert.equal(sent[0].card, 'projected-original-karama'); assert.equal(sent[0].karama, true);
assert.match(markup(game), /Use the quoted Karama auction payment for your later leader Revival/);
assert.doesNotMatch(markup(game), /projected-original-karama/);
game.decision!.player = 'a'; press(button); assert.equal(sent.length, 1);
} else if (phase === 2) { game.ready.push('p'); press(button); assert.equal(sent.length, 1); }
else { game.auction!.active = 'a'; press(button); assert.equal(sent.length, 1); }
}
for (const method of ['silent', 'onceAround'] as const) {
const game = nativeProjected(method), sent: Action[] = [];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button); assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]);
assert.match(markup(game), method === 'silent' ? /Submit zero in this Silent auction/ : /Pass this Richese bid/);
game.richeseAuction!.event = 'later-event'; press(button); assert.equal(sent.length, 1);
game.richeseAuction!.event = 'controlled-native-auction-event';
if (method === 'silent') game.richeseAuction!.submitted.push('p'); else game.richeseAuction!.active = 'a';
press(button); assert.equal(sent.length, 1);
}
});
void test('leader guide callbacks reject stale actor, event, guide, order and interrupted windows without changing native actions', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 5; },
game => { game.status = 'setup'; }, game => { game.ready = ['p']; },
game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; },
game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; },
game => { game.revivalOrder = null; }, game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; },
game => { game.leaderRevivalPromiseGuide = { phase: 4, action: { ...game.leaderRevivalPromiseGuide!.action } }; },
game => { game.leaderRevivalPromiseGuide = null; }, game => { game.leaderRevivalPromiseGuide!.phase = 3; },
game => { game.leaderRevivalPromiseGuide!.action.leader = 'kwisatz'; },
game => { game.leaderRevivalPromises[0].event = 'expired'; }, game => { game.leaderRevivalPromises[0].player = 'a'; },
game => { game.leaderRevivalPromises[0].turn = 1; }, game => { game.leaderRevivalPromises[0].fulfilled = true; },
game => { game.leaderRevivalPromises[0].released = true; }, game => { game.leaderRevivalPromises = []; },
game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; },
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(4, 'reviveLeader', true), sent: Action[] = [];
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
}
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy leader guide cannot dispatch'); } };
const button = guide(props); props.busy = true; press(button);
for (const [phase, type] of [[0, 'ready'], [1, 'ready'], [2, 'reviveLeader'], [3, 'reviveLeader'], [4, 'charity']] as const) {
const game = projected(phase, type), sent: Action[] = [];
press(guide({ game, busy: false, send(action) { sent.push(action); } })); assert.deepEqual(sent, []);
}
});
void test('named-leader guidance uses only public names and the projected guide without private eligibility inspection', () => {
for (const type of ['reviveLeader', 'reviveKwisatz', 'decision']) {
const game = projected(type === 'decision' ? 3 : 4, type), sent: Action[] = [];
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'kwisatz', 'spice', 'reserves', 'tanks', 'elites'])
Object.defineProperty(seat, key, { get() { throw Error(`No private or unneeded ${key} inspection`); }, configurable: true });
if (game.auction) Object.defineProperty(game.auction, 'card', { get() { throw Error('No unrevealed lot inspection'); } });
for (const key of ['spiceDeck', 'deck', 'discard', 'revival']) Object.defineProperty(game, key, {
get() { throw Error(`No private eligibility or ${key} inspection`); }, configurable: true,
});
assert.match(markup(game), /Your leader Revival promises/);
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.deepEqual(sent, [game.leaderRevivalPromiseGuide!.action]);
}
});