Price native Once Around and Silent funding in early movement forecasts, retaining full sealed donor locks and exact paid-sale boundaries. Add current zero/pass guidance, fixed non-CHOAM preopening Charity entitlement and shared owned normal-revival projection while actual counters remain pending. Preserve the mandatory Ix phase-opening continuation. Validation: affected rules/control tests, types/lint, production build and six gameplay/control/table-rendering programmes with eight existing-profile native runs. Wider preparations, CHOAM own unresolved opening, modules/Advanced, full rules and deployed acceptance remain open. No saved-room, database, restart or deployment operation.
96 lines
5.5 KiB
TypeScript
96 lines
5.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createElement, type ReactElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { MovementPromises } from '../components/movement-promises';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import {
|
|
createTruthMovementNativeEarlyFixture, actTruthMovementNativeEarly as act,
|
|
advanceTruthMovementNativeEarly as advance, truthMovementNativeEarlyClean as clean,
|
|
type TruthMovementNativeEarlyFixture,
|
|
} from './fixture-truth-movement-native-early';
|
|
|
|
type Props = Parameters<typeof MovementPromises>[0];
|
|
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
|
|
function controls(node: unknown): Control[] {
|
|
if (Array.isArray(node)) return node.flatMap(controls);
|
|
if (!node || typeof node !== 'object' || !('props' in node)) return [];
|
|
const props = (node as ReactElement<{ children?: unknown; disabled?: boolean; onClick?: () => void }>).props;
|
|
return [...(props.onClick ? [{ ...props, onClick: props.onClick }] : []), ...controls(props.children)];
|
|
}
|
|
const markup = (props: Props) => renderToStaticMarkup(createElement(MovementPromises, props));
|
|
|
|
/** Real source controls bind the answer and decline the original discard
|
|
* reaction. No promise, native auction, submitted bid or policy is installed. */
|
|
function bind(f: TruthMovementNativeEarlyFixture): Game {
|
|
let game = act(f.game, f.asker, f.declarationStep.action);
|
|
while (game.truthtrance?.stage === 'priority') {
|
|
const player = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(player);
|
|
game = act(game, player.id, { type: 'truthPass' });
|
|
}
|
|
game = act(game, f.asker, f.askStep.action);
|
|
assert.ok(viewGame(game, f.target).truthMovementAnswers?.includes('yes'));
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
|
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
|
assert.ok(reaction?.stage === 'offer');
|
|
const player = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(player);
|
|
game = act(game, player.id, { type: 'semutaPass', event: pending.batch.event });
|
|
}
|
|
assert.ok(clean(game), 'Finish the original Truthtrance and Semuta controls before the native auction');
|
|
assert.equal(game.phase, 2);
|
|
return game;
|
|
}
|
|
|
|
function nativeControl(method: 'onceAround' | 'silent') {
|
|
const f = createTruthMovementNativeEarlyFixture();
|
|
const game = advance(bind(f), state => state.phase === 3 && clean(state) && !!state.richeseAuction &&
|
|
!state.richeseAuction.outcome && (method === 'silent'
|
|
? !Object.hasOwn(state.richeseAuction.sealed, f.target)
|
|
: state.richeseAuction.active === f.target), [], { method, card: 'richese-nullentropy-box' });
|
|
const lot = game.richeseAuction!;
|
|
assert.equal(lot.method, method); assert.equal(lot.source, 'cache');
|
|
const expected: Action = { type: 'richeseBid', event: lot.event,
|
|
amount: method === 'silent' ? 0 : null, allyPayment: 0 };
|
|
const view = viewGame(game, f.target);
|
|
assert.deepEqual(view.movementPromisePolicy, [expected]);
|
|
const sent: Action[] = [], props = { game: view, busy: false, act: (action: Action) => { sent.push(action); } };
|
|
const html = markup(props);
|
|
assert.match(html, /Your Movement turn is still ahead/);
|
|
assert.match(html, /movement controls appear on your turn/);
|
|
assert.match(html, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
|
|
assert.match(html, /a shipment can prepare that move but does not fulfill it/);
|
|
assert.doesNotMatch(html, />Ship \d|>Move \d|>Use Ornithopter and move|>Finish movement while honoring No/);
|
|
const button = controls(MovementPromises(props));
|
|
assert.equal(button.length, 1); assert.equal(button[0].disabled, false);
|
|
assert.equal(button[0].children, method === 'silent'
|
|
? 'Submit zero in this Silent auction and preserve later movement'
|
|
: 'Pass this Richese bid and preserve later movement');
|
|
const busy = controls(MovementPromises({ ...props, busy: true }));
|
|
assert.equal(busy.length, 1); assert.equal(busy[0].disabled, true);
|
|
busy[0].onClick(); assert.deepEqual(sent, []);
|
|
assert.match(markup({ ...props, busy: true }), /Your Movement turn is still ahead/);
|
|
button[0].onClick(); assert.deepEqual(sent, [expected]);
|
|
assert.equal(sent[0], view.movementPromisePolicy![0], 'Forward the actual event-bound private policy action');
|
|
const next = act(game, f.target, sent[0]);
|
|
assert.equal(next.movementPromises![0].released, undefined);
|
|
assert.equal(next.movementPromises![0].fulfilled, undefined);
|
|
if (method === 'silent') assert.equal(next.richeseAuction!.sealed[f.target], 0);
|
|
else assert.ok(next.richeseAuction!.passed.includes(f.target));
|
|
const waiting = { ...props, game: viewGame(next, f.target) };
|
|
assert.deepEqual(waiting.game.movementPromisePolicy, []);
|
|
assert.equal(controls(MovementPromises(waiting)).length, 0);
|
|
const after = markup(waiting);
|
|
assert.match(after, /Your ground-movement promises/);
|
|
assert.match(after, /Your Movement turn is still ahead/);
|
|
assert.match(after, /Yes: you must make a ground movement of at least 8 physical forces to Carthag/);
|
|
assert.doesNotMatch(after, /<button/);
|
|
}
|
|
|
|
void test('real Once Around movement guidance submits only the current native pass and retains its notice', () => {
|
|
nativeControl('onceAround');
|
|
});
|
|
void test('real Silent movement guidance submits zero with the original native event and retains its notice', () => {
|
|
nativeControl('silent');
|
|
});
|