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,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location, splitLocation, TERRITORIES } from '../game/board';
|
||||
import { botEntryAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import type { OrnithopterMode } from '../game/ornithopter';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
const canonicalCard = richeseCards().find(card => card.effect === 'ornithopter');
|
||||
assert.ok(canonicalCard);
|
||||
export const ADVISOR_ORNITHOPTER = canonicalCard.id;
|
||||
export type OrnithopterAdvisorStep = { actor: string; action: Action };
|
||||
export const ornithopterAdvisorPlayer = (game: Game, actor = 'beneGesserit') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const ornithopterAdvisorClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actOrnithopterAdvisor(game: Game, actor: string, action: Action,
|
||||
actions: OrnithopterAdvisorStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) });
|
||||
return next;
|
||||
}
|
||||
/** Actual native choices preserve the starting advisor stance. Accompaniment
|
||||
* is selected explicitly by the source programme, never invented by a driver. */
|
||||
export function nextOrnithopterAdvisorStep(game: Game): OrnithopterAdvisorStep | null {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
if (game.status === 'setup' && game.setupStage === 'prediction')
|
||||
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'guild', turn: 10 } };
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && !ornithopterAdvisorPlayer(game).advisorSetup)
|
||||
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion' || decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
if (decision?.kind === 'richeseCache') return { actor: decision.player, action: {
|
||||
type: 'decision', event: game.richeseBidding!.event, card: ADVISOR_ORNITHOPTER,
|
||||
method: 'onceAround', direction: 'clockwise',
|
||||
} };
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
if (ornithopterAdvisorClean(game) && game.richeseAuction?.cardId === ADVISOR_ORNITHOPTER &&
|
||||
!game.richeseAuction.outcome && game.richeseAuction.active === 'beneGesserit' && game.richeseAuction.bid === 0)
|
||||
return { actor: 'beneGesserit', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceOrnithopterAdvisor(game: Game, until: (state: Game) => boolean,
|
||||
actions: OrnithopterAdvisorStep[] = []): Game {
|
||||
for (let count = 0; count < 2000; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original advisor Ornithopter programme ended before its next control');
|
||||
const step = nextOrnithopterAdvisorStep(game); assert.ok(step, 'Complete the original private plan before advancing');
|
||||
game = actOrnithopterAdvisor(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original advisor Ornithopter programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Genuine Advanced host/setup and paid native cache card. Native Atreides
|
||||
* occupies Arrakeen before BG chooses its original advisor placement. A real
|
||||
* Richese shipment there supplies one free accompanying advisor; a paid BG
|
||||
* shipment supplies the separate fighter. No dealt card, stance, clock,
|
||||
* money, force or completed receipt is installed. */
|
||||
export function createOrnithopterAdvisorFixture(options: { fighterCity?: boolean } = {}) {
|
||||
let game = createGame('ORNITHOPTERADVISORS', newPlayer('richese', 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['atreides', 'beneGesserit', 'guild'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: OrnithopterAdvisorStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actOrnithopterAdvisor(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceOrnithopterAdvisor(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < 2; count++) {
|
||||
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
reach(state => ornithopterAdvisorPlayer(state).hand.some(card => card.id === ADVISOR_ORNITHOPTER));
|
||||
const acquired = structuredClone(game);
|
||||
reach(state => state.phase === 5 && state.active === 'richese' && ornithopterAdvisorClean(state));
|
||||
const beforeForeignShipment = structuredClone(game), foreignShipment: OrnithopterAdvisorStep = {
|
||||
actor: 'richese', action: { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 },
|
||||
};
|
||||
act(foreignShipment.actor, foreignShipment.action);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
|
||||
const beforeAccompaniment = structuredClone(game), accompany: OrnithopterAdvisorStep = {
|
||||
actor: 'beneGesserit', action: { type: 'decision', accept: true, accompany: true, sector: 10, amount: 1 },
|
||||
};
|
||||
act(accompany.actor, accompany.action); reach(ornithopterAdvisorClean);
|
||||
const afterAccompaniment = structuredClone(game);
|
||||
act('richese', { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === 'beneGesserit' && ornithopterAdvisorClean(state));
|
||||
const fighterSource = options.fighterCity ? 'carthag:11' : 'polar_sink:0';
|
||||
const beforeReinforcement = structuredClone(game), reinforcement: OrnithopterAdvisorStep = {
|
||||
actor: 'beneGesserit', action: { type: 'ship', ...splitLocation(fighterSource), amount: 1, elite: 0, allyPayment: 0 },
|
||||
};
|
||||
act(reinforcement.actor, reinforcement.action); reach(ornithopterAdvisorClean);
|
||||
return { game, lobby, setup, afterSetup, acquired, beforeForeignShipment, foreignShipment,
|
||||
beforeAccompaniment, accompany, afterAccompaniment, beforeReinforcement, reinforcement,
|
||||
afterReinforcement: structuredClone(game), owner: 'beneGesserit', card: ADVISOR_ORNITHOPTER,
|
||||
advisorSource: 'arrakeen:10', fighterSource, fighterCity: options.fighterCity ?? false,
|
||||
sourceActionCount: actions.length, actions,
|
||||
staging: ['Only original scalar lotteries and unopened first-turn Spice positions are selected. The original advisorSetup, cache1 purchase, paid foreign shipment, accepted free accompaniment and paid BG reinforcement provide all physical cards, forces, stances and funding.'] };
|
||||
}
|
||||
export type OrnithopterAdvisorFixture = ReturnType<typeof createOrnithopterAdvisorFixture>;
|
||||
|
||||
/** Choose an empty printed destination from the same advisor-aware route and
|
||||
* entry quotes as the controls. Empty arrival retains the native automatic
|
||||
* stance rule; the movement card does not force a fighter declaration. */
|
||||
export function ornithopterAdvisorMove(game: Game, from: string, options: {
|
||||
range?: number; mode?: OrnithopterMode; to?: string;
|
||||
} = {}): Action {
|
||||
const view = viewGame(game, 'beneGesserit'), own = view.players.find(player => player.id === view.me)!;
|
||||
assert.ok((own.forces[from] ?? 0) > 0);
|
||||
const range = options.range ?? 1, origin = splitLocation(from).territory;
|
||||
const to = options.to ?? TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
|
||||
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
||||
.find(key => {
|
||||
const target = splitLocation(key);
|
||||
return game.players.every(player => Object.entries(player.forces).every(([source, count]) =>
|
||||
count === 0 || splitLocation(source).territory !== target.territory)) &&
|
||||
botEntryAllowed(view, own, target.territory, target.sector, 'move', origin) &&
|
||||
groundMoveRouteDistance(view, own, from, key) === range;
|
||||
});
|
||||
assert.ok(to, `The original advisor programme needs an empty printed range-${range} destination`);
|
||||
const active = view.ornithopter?.active;
|
||||
return { type: 'move', from, amount: 1, elite: 0, ...splitLocation(to),
|
||||
...(options.mode ? { movementCard: ADVISOR_ORNITHOPTER, ornithopter: options.mode }
|
||||
: active ? { ornithopterEvent: active.event } : {}) };
|
||||
}
|
||||
|
||||
/** A normal two-group flight moves one original advisor and one original
|
||||
* fighter. The city variant moves the last fighter out first, so its second
|
||||
* advisor group must use newly recomputed ordinary range one. */
|
||||
export function completeOrnithopterAdvisors(fixture: OrnithopterAdvisorFixture,
|
||||
mode: OrnithopterMode = 'twoGroups') {
|
||||
let game = structuredClone(fixture.game);
|
||||
const actions: OrnithopterAdvisorStep[] = [], moves: { before: Game; step: OrnithopterAdvisorStep; after: Game }[] = [];
|
||||
const move = (from: string, settings: Parameters<typeof ornithopterAdvisorMove>[2] = {}) => {
|
||||
const before = structuredClone(game), step: OrnithopterAdvisorStep = {
|
||||
actor: fixture.owner, action: ornithopterAdvisorMove(game, from, settings),
|
||||
};
|
||||
game = actOrnithopterAdvisor(game, step.actor, step.action, actions);
|
||||
game = advanceOrnithopterAdvisor(game, ornithopterAdvisorClean, actions);
|
||||
moves.push({ before, step, after: structuredClone(game) });
|
||||
return location(String(step.action.territory), Number(step.action.sector));
|
||||
};
|
||||
const firstSource = mode === 'twoGroups' && fixture.fighterCity ? fixture.fighterSource : fixture.advisorSource;
|
||||
const firstDestination = move(firstSource, { mode, range: mode === 'range3' || fixture.fighterCity ? 3 : 1 });
|
||||
const first = structuredClone(game);
|
||||
if (mode === 'twoGroups') move(fixture.fighterCity ? fixture.advisorSource : fixture.fighterSource);
|
||||
const completed = structuredClone(game);
|
||||
game = actOrnithopterAdvisor(game, fixture.owner, { type: 'endMovement' }, actions);
|
||||
return { game, completed, first, firstDestination, firstSource, moves, actions, mode };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {applyAction, createGame, newPlayer, type Game, type Action} from '../game/engine';
|
||||
import {baseDeck} from '../game/cards';
|
||||
import {richeseCards} from '../game/richese-cards';
|
||||
import {TERRITORIES, gameDistance, splitLocation} from '../game/board';
|
||||
import {fighterCount, isAdvisor} from '../game/advisors';
|
||||
|
||||
const card = 'richese-ornithopter', remote = 'red_chasm:7', near = 'imperial_basin:10';
|
||||
/** Controlled rule geometry, separate from the original-action acquisition fixture. */
|
||||
function fixture() {
|
||||
const g = createGame('ADVISORRULES', newPlayer('p','Bene Gesserit','beneGesserit'), true, ['choam']);
|
||||
g.players.push(newPlayer('q','Atreides','atreides'));
|
||||
Object.assign(g, {status:'playing',turn:2,phase:5,active:'p',order:['p','q'],
|
||||
movementRemaining:['p','q'],storm:18,deck:baseDeck(),richeseCache:richeseCards()});
|
||||
const p = g.players[0], q = g.players[1];
|
||||
p.hand = [g.richeseCache!.splice(g.richeseCache!.findIndex(c => c.id === card),1)[0]];
|
||||
p.forces = {[near]:1,[remote]:2}; p.reserves = 17; p.shipped = true;
|
||||
p.advisors = {imperial_basin:{},red_chasm:{}};
|
||||
q.forces = {[near]:1,[remote]:1}; q.reserves = 18;
|
||||
return g;
|
||||
}
|
||||
function atDistance(g: Game, from: string, steps: number): string {
|
||||
const target = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`)).find(key =>
|
||||
splitLocation(key).sector !== g.storm &&
|
||||
!g.players.some(p => p.forces[key]) &&
|
||||
gameDistance(g,from,key,key => splitLocation(key).sector === g.storm) === steps);
|
||||
assert.ok(target); return target;
|
||||
}
|
||||
function move(g: Game, from: string, to: string, extra: Partial<Action> = {}) {
|
||||
return applyAction(g,'p',{type:'move',from,amount:1,...splitLocation(to),...extra});
|
||||
}
|
||||
|
||||
void test('advisors do not grant city access, while a separate fighter city permits normal advisor movement', () => {
|
||||
const g = fixture();
|
||||
g.players[0].forces['arrakeen:10'] = 1; g.players[0].reserves--;
|
||||
g.players[0].advisors!.arrakeen = {};
|
||||
g.players[1].forces['arrakeen:10'] = 1; g.players[1].reserves--;
|
||||
const target = atDistance(g,remote,3), before = structuredClone(g);
|
||||
assert.equal(fighterCount(g.players[0],'arrakeen'),0);
|
||||
assert.throws(() => move(g,remote,target), /more than 1/);
|
||||
assert.throws(() => move(g,remote,target,{movementCard:card,ornithopter:'twoGroups'}), /more than 1/);
|
||||
assert.deepEqual(g,before);
|
||||
g.players[0].forces['carthag:11'] = 1; g.players[0].reserves--;
|
||||
const done = move(g,remote,target);
|
||||
assert.equal(done.players[0].forces[target],1);
|
||||
assert.equal(done.players[0].moved,1);
|
||||
assert.ok(done.players[0].hand.some(c => c.id === card), 'City access does not play the physical card');
|
||||
});
|
||||
|
||||
void test('a card group arriving as a fighter in an empty city enables current normal range for the second advisor group', () => {
|
||||
const g = fixture();
|
||||
const first = move(g,near,'arrakeen:10',{movementCard:card,ornithopter:'twoGroups'});
|
||||
assert.equal(isAdvisor(first.players[0],'arrakeen'),false);
|
||||
assert.equal(fighterCount(first.players[0],'arrakeen'),1);
|
||||
assert.equal(first.ornithopter!.cohort!.forces[remote],2);
|
||||
const target = atDistance(first,remote,3);
|
||||
const done = move(first,remote,target,{ornithopterEvent:first.ornithopter!.event});
|
||||
assert.equal(done.players[0].moved,2);
|
||||
assert.equal(done.players[0].forces[target],1);
|
||||
assert.equal(done.discard.filter(c => c.id === card).length,1);
|
||||
});
|
||||
|
||||
void test('a first card group remaining advisors in an occupied city grants no range to the second group', () => {
|
||||
const g = fixture();
|
||||
g.players[1].forces['arrakeen:10'] = 1; g.players[1].reserves--;
|
||||
const first = move(g,near,'arrakeen:10',{movementCard:card,ornithopter:'twoGroups'});
|
||||
assert.equal(isAdvisor(first.players[0],'arrakeen'),true);
|
||||
assert.equal(fighterCount(first.players[0],'arrakeen'),0);
|
||||
const before = structuredClone(first);
|
||||
assert.throws(() => move(first,remote,atDistance(first,remote,3),
|
||||
{ornithopterEvent:first.ornithopter!.event}), /more than 1/);
|
||||
assert.deepEqual(first,before);
|
||||
const done = move(first,remote,atDistance(first,remote,1),{ornithopterEvent:first.ornithopter!.event});
|
||||
assert.equal(done.players[0].moved,2);
|
||||
assert.equal(done.discard.filter(c => c.id === card).length,1);
|
||||
});
|
||||
|
||||
void test('card range cannot bypass a new advisor fighter lock or the native two-faction entry distinction', () => {
|
||||
const g = fixture();
|
||||
g.players[0].advisors!.imperial_basin = {lockedTurn:g.turn};
|
||||
g.players[1].forces['arrakeen:10'] = 1; g.players[1].reserves--;
|
||||
const third = newPlayer('h','Harkonnen','harkonnen');
|
||||
third.forces = {'arrakeen:10':1}; third.reserves = 19;
|
||||
g.players.push(third); g.order.push('h'); g.movementRemaining!.push('h');
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => move(g,near,'arrakeen:10',{fighters:true,movementCard:card,ornithopter:'range3'}), /cannot flip/);
|
||||
assert.deepEqual(g,before);
|
||||
const done = move(g,near,'arrakeen:10',{movementCard:card,ornithopter:'range3'});
|
||||
assert.equal(isAdvisor(done.players[0],'arrakeen'),true);
|
||||
assert.equal(done.players[0].advisors!.arrakeen.lockedTurn,g.turn);
|
||||
assert.equal(fighterCount(done.players[0],'arrakeen'),0);
|
||||
assert.equal(done.discard.filter(c => c.id === card).length,1);
|
||||
});
|
||||
@@ -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), '');
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { fighterCount, isAdvisor } from '../game/advisors';
|
||||
import { botMovementRange, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { botActions } from '../game/bots';
|
||||
import { splitLocation } from '../game/board';
|
||||
import {
|
||||
actOrnithopterAdvisor, completeOrnithopterAdvisors, createOrnithopterAdvisorFixture,
|
||||
ornithopterAdvisorMove, ornithopterAdvisorPlayer as player, type OrnithopterAdvisorFixture,
|
||||
} from './fixture-ornithopter-advisors';
|
||||
|
||||
const own = (game: Game) => viewGame(game, 'beneGesserit').players.find(player => player.id === 'beneGesserit')!;
|
||||
const normalRange = (game: Game) => botMovementRange(viewGame(game, 'beneGesserit'), own(game), 0);
|
||||
const total = (game: Game) => Object.values(player(game).forces).reduce((sum, count) => sum + count, 0);
|
||||
function source(f: OrnithopterAdvisorFixture) {
|
||||
assert.equal(f.game.advanced, true);
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'advisorSetup' && step.action.territory === 'arrakeen'));
|
||||
assert.equal(player(f.afterSetup).forces[f.advisorSource], 1);
|
||||
assert.equal(isAdvisor(player(f.afterSetup), 'arrakeen'), true);
|
||||
assert.equal(player(f.afterSetup, 'atreides').forces[f.advisorSource], 10);
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquired).hand.some(card => card.id === f.card));
|
||||
assert.equal(player(f.beforeForeignShipment, 'richese').spice - player(f.afterAccompaniment, 'richese').spice, 1);
|
||||
assert.equal(f.beforeAccompaniment.decision?.kind, 'advisor');
|
||||
assert.equal(player(f.beforeAccompaniment).reserves - player(f.afterAccompaniment).reserves, 1);
|
||||
assert.equal(player(f.beforeAccompaniment).spice, player(f.afterAccompaniment).spice);
|
||||
assert.equal(player(f.game).forces[f.advisorSource], 2);
|
||||
assert.equal(player(f.game).advisors!.arrakeen.lockedTurn, f.game.turn);
|
||||
assert.equal(player(f.beforeReinforcement).spice - player(f.afterReinforcement).spice, f.fighterCity ? 1 : 2);
|
||||
assert.equal(player(f.game).forces[f.fighterSource], 1);
|
||||
assert.equal(isAdvisor(player(f.game), splitLocation(f.fighterSource).territory), false);
|
||||
assert.equal(total(f.game), 3);
|
||||
assert.equal(fighterCount(player(f.game), 'arrakeen'), 0);
|
||||
assert.equal(normalRange(f.game), f.fighterCity ? 3 : 1);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.equal(viewGame(f.game, f.owner).ornithopter!.blocked, null);
|
||||
}
|
||||
function settled(f: OrnithopterAdvisorFixture, result: ReturnType<typeof completeOrnithopterAdvisors>, count: number) {
|
||||
const done = result.completed;
|
||||
assert.equal(player(done).moved, count);
|
||||
assert.equal(own(done).movesAllowed, count);
|
||||
assert.equal(done.ornithopter ?? null, null);
|
||||
assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(done).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(total(done), total(f.game));
|
||||
assert.equal(player(done).spice, player(f.game).spice);
|
||||
assert.equal(player(done).reserves, player(f.game).reserves);
|
||||
assert.equal(player(done).tanks, player(f.game).tanks);
|
||||
assert.equal(result.actions.at(-1)!.action.type, 'endMovement');
|
||||
}
|
||||
|
||||
void test('genuine Advanced advisors use paid physical Ornithopter range three without fighter city access', () => {
|
||||
const f = createOrnithopterAdvisorFixture();
|
||||
source(f);
|
||||
const proposed = ornithopterAdvisorMove(f.game, f.advisorSource, { mode: 'range3', range: 3 });
|
||||
const ordinary = { ...proposed };
|
||||
delete ordinary.movementCard;
|
||||
delete ordinary.ornithopter;
|
||||
assert.throws(() => actOrnithopterAdvisor(f.game, f.owner, ordinary), /far|range|route|long|more than/i,
|
||||
'Advisors occupying Arrakeen do not supply ordinary three-territory access');
|
||||
const result = completeOrnithopterAdvisors(f, 'range3');
|
||||
assert.equal(groundMoveRouteDistance(viewGame(f.game, f.owner), own(f.game), f.advisorSource, result.firstDestination), 3);
|
||||
assert.equal(isAdvisor(player(result.completed), 'arrakeen'), true);
|
||||
assert.equal(player(result.completed).forces[f.advisorSource], 1);
|
||||
assert.equal(isAdvisor(player(result.completed), splitLocation(result.firstDestination).territory), false);
|
||||
assert.equal(fighterCount(player(result.completed), splitLocation(result.firstDestination).territory), 1,
|
||||
'An advisor arriving alone in an empty territory automatically becomes a fighter');
|
||||
assert.equal(normalRange(result.completed), 1);
|
||||
settled(f, result, 1);
|
||||
});
|
||||
|
||||
void test('genuine Advanced advisor and fighter groups remain distinct within one physical two-group flight', () => {
|
||||
const f = createOrnithopterAdvisorFixture();
|
||||
source(f);
|
||||
const result = completeOrnithopterAdvisors(f);
|
||||
assert.equal(result.moves[0].step.action.from, f.advisorSource);
|
||||
assert.equal(result.moves[1].step.action.from, f.fighterSource);
|
||||
assert.equal(isAdvisor(player(result.moves[0].before), 'arrakeen'), true);
|
||||
assert.equal(isAdvisor(player(result.moves[1].before), splitLocation(f.fighterSource).territory), false);
|
||||
const active = viewGame(result.first, f.owner).ornithopter!.active!;
|
||||
assert.equal(active.completed, 1);
|
||||
assert.equal(active.cohort!.forces[f.advisorSource], 1);
|
||||
assert.equal(active.cohort!.forces[f.fighterSource], 1);
|
||||
assert.equal(active.cohort!.forces[result.firstDestination] ?? 0, 0);
|
||||
assert.equal(isAdvisor(player(result.first), splitLocation(result.firstDestination).territory), false);
|
||||
const repeated = ornithopterAdvisorMove(result.first, result.firstDestination);
|
||||
assert.throws(() => actOrnithopterAdvisor(result.first, f.owner, repeated), /cohort|unmoved|original|force/i,
|
||||
'The first advisor group cannot be reused merely because its empty arrival changed its stance');
|
||||
assert.equal(result.moves[1].step.action.ornithopterEvent, active.event);
|
||||
assert.equal(player(result.completed).forces[f.advisorSource], 1);
|
||||
assert.equal(isAdvisor(player(result.completed), 'arrakeen'), true);
|
||||
settled(f, result, 2);
|
||||
});
|
||||
|
||||
void test('genuine two-group flight recomputes normal advisor range after the last city fighter leaves', () => {
|
||||
const f = createOrnithopterAdvisorFixture({ fighterCity: true });
|
||||
source(f);
|
||||
const result = completeOrnithopterAdvisors(f);
|
||||
assert.equal(result.moves[0].step.action.from, 'carthag:11');
|
||||
assert.equal(groundMoveRouteDistance(viewGame(f.game, f.owner), own(f.game), f.fighterSource, result.firstDestination), 3);
|
||||
assert.equal(fighterCount(player(result.first), 'carthag'), 0);
|
||||
assert.equal(fighterCount(player(result.first), 'arrakeen'), 0);
|
||||
assert.equal(player(result.first).forces[f.advisorSource], 2);
|
||||
assert.equal(normalRange(result.first), 1);
|
||||
const tooFar = ornithopterAdvisorMove(result.first, f.advisorSource, { range: 3 });
|
||||
assert.throws(() => actOrnithopterAdvisor(result.first, f.owner, tooFar), /far|range|route|long|more than/i,
|
||||
'Two-group mode uses current normal range rather than retaining the first group\'s city access');
|
||||
assert.equal(result.moves[1].step.action.from, f.advisorSource);
|
||||
assert.equal(result.moves[1].step.action.movementCard, undefined);
|
||||
assert.equal(typeof result.moves[1].step.action.ornithopterEvent, 'string');
|
||||
assert.equal(normalRange(result.completed), 1);
|
||||
settled(f, result, 2);
|
||||
});
|
||||
|
||||
void test('all four minimal policies retain legal original advisor card and unmoved-group continuations', () => {
|
||||
const f = createOrnithopterAdvisorFixture();
|
||||
const { first } = completeOrnithopterAdvisors(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const startView = viewGame(f.game, f.owner);
|
||||
startView.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const advisor = botActions(startView).find(action => action.type === 'move' &&
|
||||
action.movementCard === f.card && action.from === f.advisorSource);
|
||||
assert.ok(advisor, `${difficulty} retains a physical-card move from the actual advisor source`);
|
||||
const started = actOrnithopterAdvisor(structuredClone(f.game), f.owner, advisor);
|
||||
assert.ok(player(started).moved === 1 || started.response || started.decision);
|
||||
const pendingView = viewGame(first, f.owner);
|
||||
pendingView.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const second = botActions(pendingView).find(action => action.type === 'move' &&
|
||||
action.ornithopterEvent === pendingView.ornithopter!.active!.event);
|
||||
assert.ok(second, `${difficulty} retains a distinct original second group`);
|
||||
const completed = actOrnithopterAdvisor(structuredClone(first), f.owner, second);
|
||||
assert.ok(player(completed).moved === 2 || completed.response || completed.decision);
|
||||
}
|
||||
});
|
||||
@@ -67,12 +67,11 @@ void test('played card escrow, owner-only remaining quotas and ended movement su
|
||||
assert.equal(ended.active,'q');
|
||||
});
|
||||
void test('invalid source, blocked route, committed card and explicitly unresolved combinations are immutable',()=>{
|
||||
for(const change of ['range','storm','prior','advisor','gift','wrongMode'] as const){
|
||||
const g=fixture(change==='advisor'?'beneGesserit':'emperor',change==='advisor');
|
||||
for(const change of ['range','storm','prior','gift','wrongMode'] as const){
|
||||
const g=fixture();
|
||||
let key=destination(g,origin,3);const extra:Partial<Action>={movementCard:card,ornithopter:'range3'};
|
||||
if(change==='range')key=destination(g,origin,4);if(change==='storm')g.storm=splitLocation(key).sector;
|
||||
if(change==='prior')g.players[0].moved=1;
|
||||
if(change==='advisor'){g.players[0].advisors={red_chasm:{}};g.players[1].forces={[origin]:1};}
|
||||
if(change==='wrongMode')extra.ornithopter='both';
|
||||
if(change==='gift')g.pendingRicheseGift={event:'reserved',intent:{owner:'p',recipient:'q',cardId:card},turn:g.turn,phase:g.phase,resume:{response:null,decision:null,pendingKarama:null,phaseOpening:null}} as Game['pendingRicheseGift'];
|
||||
const before=structuredClone(g);assert.throws(()=>move(g,key,extra),change);assert.deepEqual(g,before);
|
||||
|
||||
Reference in new issue
Block a user