Adopt the supplied advisor/city distinction: advisors do not grant city access, but both printed physical-card modes permit advisor groups. Retain native stance/arrival/locks and distinct original cohorts; use shared advisor-aware paths and entry for minimal bot candidates. Add persistent guidance and genuine paid-acquisition/native-advisor regressions. Validation: frozen10-file87/87, types/lint/build and three original-action/rendered-table programmes. Actual host/setup, cache1 purchase, foreign shipment/accompaniment and paid BG reinforcement cover range3, two groups and leaving the last city fighter. Eight minimal policy proposals remain legal; controlled geometry and UI checks are separate. No wider combinations, complete modes, final assurance or browser/deployed acceptance claimed. No saved-room/reset/restart/deployment operation.
110 lines
6.6 KiB
TypeScript
110 lines
6.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { createElement } from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { OrnithopterMovement } from '../components/ornithopter-movement';
|
||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||
import { richeseCards } from '../game/richese-cards';
|
||
import { botMovementRange } from '../game/bot-mobility';
|
||
|
||
const firstSource = 'arrakeen:10', secondSource = 'carthag:11';
|
||
const move = (from = firstSource, amount = 1): Action => ({
|
||
type: 'move', from, amount, elite: 0, territory: 'polar_sink', sector: 0,
|
||
});
|
||
|
||
/** Controlled personalized UI projection, not an earned game history or native
|
||
* runtime proof. Genuine acquisition, advisor entry/flip and movement are
|
||
* verified by the separate original-action integration fixtures. */
|
||
function controlledView(active = false): GameView {
|
||
const source = createGame('ORNITHOPTERADVISORUI', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['choam']);
|
||
joinGame(source, newPlayer('emperor', 'Emperor', 'emperor'));
|
||
const game = viewGame(source, 'bg'), card = richeseCards().find(card => card.effect === 'ornithopter'); assert.ok(card);
|
||
game.status = 'playing'; game.phase = 5; game.turn = 2; game.active = 'bg'; game.storm = 18;
|
||
const own = game.players.find(player => player.id === 'bg')!;
|
||
own.forces = { [firstSource]: 2, [secondSource]: 2 };
|
||
own.advisors = { arrakeen: {}, carthag: {} };
|
||
game.players.find(player => player.id === 'emperor')!.forces = { [firstSource]: 1, [secondSource]: 1 };
|
||
game.ornithopter = { card, blocked: null,
|
||
modes: [{ mode: 'range3', blocked: null }, { mode: 'twoGroups', blocked: null }],
|
||
active: active ? { event: 'original-flight-event', mode: 'twoGroups', completed: 1, remaining: 1,
|
||
cohort: { forces: { [secondSource]: 2 }, elites: {} } } : null };
|
||
return game;
|
||
}
|
||
function markup(game: GameView, draft: Action | null = move(), busy = false, unavailableReason?: string): string {
|
||
return renderToStaticMarkup(createElement(OrnithopterMovement, {
|
||
game, move: draft, busy, unavailableReason,
|
||
act() { assert.fail('Static controls must not submit a movement or start a countdown'); },
|
||
}));
|
||
}
|
||
function movementButton(html: string): string {
|
||
const button = /<button\b[^>]*>(?:Play Ornithopter and move selected group|Move next selected group)<\/button>/.exec(html);
|
||
assert.ok(button, html); return button[0];
|
||
}
|
||
|
||
void test('initial Advanced advisor controls offer both printed modes with persistent guidance and an enabled selected group', () => {
|
||
const html = markup(controlledView());
|
||
assert.match(html, /Use Ornithopter for this movement/);
|
||
assert.match(html, /<option[^>]*value="range3"[^>]*>One group, up to three territories/);
|
||
assert.match(html, /<option[^>]*value="twoGroups"[^>]*>Two different groups, normal movement/);
|
||
assert.doesNotMatch(html, /awaits.*ruling|advisor use.*unavailable/i);
|
||
assert.doesNotMatch(movementButton(html), /\sdisabled=/);
|
||
assert.match(html, /<p class="notice" id="[^"]+-advisor-guidance">Advanced Bene Gesserit advisors can use both card modes/);
|
||
assert.match(html, /one-group mode independently gives range three/);
|
||
assert.match(html, /Two-group mode uses your current normal range and different original forces/);
|
||
assert.match(html, /Normal entry and advisor conversion still apply/);
|
||
assert.match(movementButton(html), /aria-describedby="[^"]+-advisor-guidance"/);
|
||
});
|
||
|
||
void test('the active two-group advisor control retains the original distinct unmoved cohort', () => {
|
||
const html = markup(controlledView(true), move(secondSource, 2));
|
||
assert.match(html, /Ornithopter movement in progress/);
|
||
assert.match(html, /1 group moved · 1 remaining/);
|
||
assert.match(html, /The next group must use different original forces/);
|
||
assert.match(html, /Original unmoved forces available for selection/);
|
||
assert.match(html, /Carthag, sector 11: 2 forces/);
|
||
assert.doesNotMatch(movementButton(html), /\sdisabled=/);
|
||
assert.match(html, /Advanced Bene Gesserit advisors can use both card modes/);
|
||
});
|
||
|
||
void test('busy and unavailable advisor movement remain disabled with a readable explanation', () => {
|
||
assert.match(movementButton(markup(controlledView(), move(), true)), /\sdisabled=""/);
|
||
const unavailable = markup(controlledView(), move(), false, 'The original territory entry is blocked.');
|
||
assert.match(unavailable, /The original territory entry is blocked/);
|
||
assert.match(movementButton(unavailable), /\sdisabled=""/);
|
||
assert.match(movementButton(unavailable), /aria-describedby="[^"]+-advisor-guidance [^"]+-reason"/);
|
||
const blocked = controlledView(); blocked.ornithopter!.blocked = 'Finish the original response first.';
|
||
assert.match(markup(blocked), /Finish the original response first/);
|
||
assert.match(movementButton(markup(blocked)), /\sdisabled=""/);
|
||
assert.match(movementButton(markup(controlledView(), null)), /\sdisabled=""/);
|
||
});
|
||
|
||
void test('already moved and stale physical advisor cohorts cannot be submitted again', () => {
|
||
const reused = markup(controlledView(true), move(firstSource));
|
||
assert.match(reused, /Choose forces that have not moved with this card. The first group cannot move again/);
|
||
assert.match(movementButton(reused), /\sdisabled=""/);
|
||
const stale = controlledView(true);
|
||
stale.players.find(player => player.id === 'bg')!.forces[secondSource] = 0;
|
||
const lost = markup(stale, move(secondSource, 2));
|
||
assert.match(lost, /selected force counts are no longer available/i);
|
||
assert.match(movementButton(lost), /\sdisabled=""/);
|
||
});
|
||
|
||
void test('advisors alone in either city grant no city range even while the independent card control is available', () => {
|
||
const game = controlledView(), own = game.players.find(player => player.id === 'bg')!;
|
||
assert.equal(botMovementRange(game, own, 0), 1);
|
||
const html = markup(game);
|
||
assert.match(html, /Advisors in Arrakeen or Carthag do not grant the city’s movement range/);
|
||
assert.doesNotMatch(movementButton(html), /\sdisabled=/);
|
||
own.advisors = {};
|
||
assert.equal(botMovementRange(game, own, 0), 3, 'The native public range helper still distinguishes city fighters');
|
||
});
|
||
|
||
void test('Advanced BG guidance does not appear for Basic or another faction, and absent offers stay absent', () => {
|
||
const basic = controlledView(); basic.advanced = false;
|
||
assert.doesNotMatch(markup(basic), /Advanced Bene Gesserit advisors/);
|
||
const other = controlledView(); other.players.find(player => player.id === 'bg')!.faction = 'emperor';
|
||
assert.doesNotMatch(markup(other), /Advanced Bene Gesserit advisors/);
|
||
const absent = controlledView(); absent.ornithopter = null;
|
||
assert.equal(markup(absent), '');
|
||
});
|