Allow Advanced advisors to use the physical Ornithopter
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.
This commit is contained in:
1 parent
884a09fa92
commit
7fe84ffbbd
14 files changed
+634
-36
No files matched your search
@@ -0,0 +1,109 @@
|
||||
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), '');
|
||||
});
|
||||
Reference in new issue
Block a user