Support Planetologist range for concealed Richese No-Fields
This commit is contained in:
1 parent
2c3dde51d4
commit
f6e6671ac5
14 files changed
+1041
-181
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameTerritories, location, splitLocation, territory } from '../game/board';
|
||||
import { botEntryAllowed, botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import { planetologistLeader } from '../game/planetologist-movement';
|
||||
import type { NoFieldValue } from '../game/richese-no-field';
|
||||
import { createSelectedDeckSkillsSetup, assertSelectedDeckSkillsInventory } from './fixture-selected-deck-skills';
|
||||
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
|
||||
|
||||
export type PlanetologistNoFieldStep = { actor: string; action: Action };
|
||||
export const planetologistNoFieldClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function planetologistNoFieldPlayer(game: Game, actor = 'richese') {
|
||||
const player = game.players.find(candidate => candidate.id === actor);
|
||||
assert.ok(player, `The original ${actor} seat must remain present`);
|
||||
return player;
|
||||
}
|
||||
export function planetologistNoFieldInventory(game: Game) {
|
||||
assertSelectedDeckSkillsInventory(game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
return { treachery: cards.map(card => card.id).sort(),
|
||||
skills: [...game.leaderSkills!.deck, ...game.leaderSkills!.assignments.map(assignment => assignment.skill),
|
||||
...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards)].sort(),
|
||||
traitors: [...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)].sort() };
|
||||
}
|
||||
/** The actual move spends no card, physical reserve or spice, including for0. */
|
||||
export function planetologistNoFieldPhysical(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, spice: player.spice, reserves: player.reserves,
|
||||
tanks: player.tanks, forces: structuredClone(player.forces), elites: structuredClone(player.elites),
|
||||
leaders: structuredClone(player.leaders), hand: player.hand.map(card => card.id) }));
|
||||
}
|
||||
export function actPlanetologistNoField(game: Game, actor: string, action: Action,
|
||||
actions: PlanetologistNoFieldStep[] = []): Game {
|
||||
const next = applyAction(game, actor, action);
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
export function advancePlanetologistNoField(game: Game, until: (state: Game) => boolean,
|
||||
actions: PlanetologistNoFieldStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Planetologist/No-Field chronology ended before its owned boundary');
|
||||
const step = nextChoamIndependentSkillsStep(game);
|
||||
assert.ok(step, `Supply the real ${game.turn}/${game.phase}/${game.decision?.kind} control before continuing`);
|
||||
game = actPlanetologistNoField(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The native Planetologist/No-Field chronology did not reach its finite boundary');
|
||||
}
|
||||
|
||||
function destinations(game: Game, actor: string, source: string) {
|
||||
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
|
||||
const emptyRocks = gameTerritories(game).filter(place => place.type === 'rock' &&
|
||||
place.id !== splitLocation(source).territory && !game.players.some(player => presenceAt(player, place.id)))
|
||||
.flatMap(place => place.sectors.map(sector => location(place.id, sector)));
|
||||
const targetLocation = emptyRocks.find(target => groundMoveRouteDistance(view, me, source, target) === 2 &&
|
||||
botGroundMoveAllowed(view, me, source, target, 0, 'range') && !botGroundMoveAllowed(view, me, source, target, 0));
|
||||
const tooFarLocation = emptyRocks.find(target => groundMoveRouteDistance(view, me, source, target) === 3 &&
|
||||
botEntryAllowed(view, me, splitLocation(target).territory, splitLocation(target).sector, 'move', splitLocation(source).territory));
|
||||
return targetLocation && tooFarLocation ? { targetLocation, tooFarLocation } : null;
|
||||
}
|
||||
|
||||
/** Original public Basic/Advanced R/A/E +choam/Skills. The existing fixture selects only
|
||||
* initial unopened scalar/skill lotteries; every later step is a native action.
|
||||
* Optional companion1 is earned by an actual turn-one paid shipment, then the
|
||||
* original marker is really shipped beside it on turn two. No state is installed. */
|
||||
export function createPlanetologistNoFieldFixture(options: { advanced?: boolean; value?: NoFieldValue; companions?: 0 | 1 } = {}) {
|
||||
const advanced = options.advanced ?? false, value = options.value ?? 5, companions = options.companions ?? 0;
|
||||
const setup = createSelectedDeckSkillsSetup({ advanced,
|
||||
factions: ['richese', 'atreides', 'emperor'], expansions: ['choam'], skill: 'planetologist' });
|
||||
const actor = setup.owner, actions: PlanetologistNoFieldStep[] = [...setup.actions];
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const act = (owner: string, action: Action) => { game = actPlanetologistNoField(game, owner, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistNoField(game, until, actions); };
|
||||
const originalInventory = planetologistNoFieldInventory(game);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && planetologistNoFieldClean(state));
|
||||
let source: string | null = null;
|
||||
for (const place of gameTerritories(game)) {
|
||||
if (place.type !== 'rock' || game.players.some(player => presenceAt(player, place.id))) continue;
|
||||
for (const sector of place.sectors) {
|
||||
if (sector < 6 || sector > 16 || sector === game.storm) continue;
|
||||
const candidate = location(place.id, sector);
|
||||
if (destinations(game, actor, candidate)) { source = candidate; break; }
|
||||
}
|
||||
if (source) break;
|
||||
}
|
||||
assert.ok(source, 'An empty non-city source must have ordinary legal distance-two and distance-three rock routes');
|
||||
const sourceLocation = splitLocation(source);
|
||||
let companionShipment: { before: Game; step: PlanetologistNoFieldStep; after: Game } | null = null;
|
||||
if (companions) {
|
||||
const before = structuredClone(game), step: PlanetologistNoFieldStep = { actor,
|
||||
action: { type: 'ship', ...sourceLocation, amount: companions, elite: 0, allyPayment: 0 } };
|
||||
act(actor, step.action); reach(planetologistNoFieldClean);
|
||||
companionShipment = { before, step, after: structuredClone(game) };
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistNoFieldClean(state));
|
||||
assert.equal(planetologistNoFieldPlayer(game, actor).forces[source], companions,
|
||||
'The actual earlier shipment, rather than a staged pool, supplies the companion');
|
||||
}
|
||||
const me = planetologistNoFieldPlayer(game, actor), token = me.noField!.tokens.find(candidate => candidate.value === value);
|
||||
assert.ok(token); assert.equal(me.noField!.deployed, null);
|
||||
assert.ok(!me.forces['arrakeen:10'] && !me.forces['carthag:11'], 'No city ornithopter supplies the extra range');
|
||||
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistNoFieldStep = { actor,
|
||||
action: { type: 'ship', noField: token.id, event: me.noFieldEvent, ...sourceLocation } };
|
||||
act(actor, shipmentStep.action); reach(planetologistNoFieldClean);
|
||||
const afterShipment = structuredClone(game), routes = destinations(game, actor, source);
|
||||
assert.ok(routes, 'The original post-shipment board retains the two bounded legal routes');
|
||||
assert.equal(planetologistNoFieldPlayer(beforeShipment, actor).spice - planetologistNoFieldPlayer(game, actor).spice, 2);
|
||||
assert.equal(planetologistNoFieldPlayer(game, actor).reserves, planetologistNoFieldPlayer(beforeShipment, actor).reserves);
|
||||
assert.equal(planetologistNoFieldPlayer(game, actor).moved, 0);
|
||||
assert.equal(planetologistNoFieldPlayer(game, actor).noField!.deployed!.tokenId, token.id);
|
||||
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
|
||||
const target = splitLocation(routes.targetLocation), tooFar = splitLocation(routes.tooFarLocation);
|
||||
const forces = companions ? { [source]: companions } : {};
|
||||
const action: Action = { type: 'move', planetologist: 'range', forces, noField: token.id,
|
||||
event: planetologistNoFieldPlayer(game, actor).noFieldEvent, ...target };
|
||||
const ordinary: Action = { ...action }; delete ordinary.planetologist;
|
||||
const tooFarAction: Action = { ...action, ...tooFar };
|
||||
assert.equal(territory(sourceLocation.territory).type, 'rock');
|
||||
assert.deepEqual(planetologistNoFieldInventory(game), originalInventory);
|
||||
return { game, sourceGame: structuredClone(game), initial: setup.initial, started: setup.started,
|
||||
offered: setup.offered, afterSetup: setup.afterSetup, setup, actor, owner: actor, richese: actor,
|
||||
observer: setup.seats.emperor!, opponent: setup.seats.atreides!, advanced, value, token: structuredClone(token), trainer,
|
||||
source, sourceLocation, target, targetLocation: routes.targetLocation, tooFar, tooFarLocation: routes.tooFarLocation,
|
||||
action, ordinary, tooFarAction, companions, forces, companionShipment, beforeShipment, shipmentStep, afterShipment,
|
||||
originalInventory, originalPhysical: planetologistNoFieldPhysical(game),
|
||||
originalNoField: structuredClone(planetologistNoFieldPlayer(game, actor).noField!),
|
||||
originalEvent: planetologistNoFieldPlayer(game, actor).noFieldEvent!,
|
||||
endMovement: { type: 'endMovement' } satisfies Action,
|
||||
actions, sourceActionCount: actions.length,
|
||||
staging: [...setup.staging,
|
||||
'Original public Basic/Advanced Richese/Atreides/Emperor +choam/Leader Skills module, ready/start, original Treachery/Traitor deals and real private skill training. Only the existing original unopened skill/scalar lotteries are selected; no played card, force, wallet, leader, marker, phase or receipt is installed. Advanced remains preview evidence.',
|
||||
companions ? 'An actual first-turn paid one-force shipment creates the physical companion on empty non-city rock. Native end-turn, Storm, Spice, Charity, Bidding and Revival controls reach turn two, then an actual concealed No-Field shipment joins that same source.'
|
||||
: 'Native first Storm, Spice, Charity, Bidding and Revival controls reach the first Richese movement turn. An actual paid concealed0/3/5 shipment parks its original token on empty non-city rock without withdrawing physical reserves.',
|
||||
'Stop before the actual distance-two Planetologist range action. The same empty board offers a distance-three rejection without city ornithopters. Token movement, event rotation, one movement allowance and later end-turn continuation remain caller-owned outcomes.',
|
||||
] };
|
||||
}
|
||||
export type PlanetologistNoFieldFixture = ReturnType<typeof createPlanetologistNoFieldFixture>;
|
||||
export function movePlanetologistNoField(fixture: PlanetologistNoFieldFixture,
|
||||
game = structuredClone(fixture.game), actions: PlanetologistNoFieldStep[] = []) {
|
||||
return actPlanetologistNoField(game, fixture.actor, fixture.action, actions);
|
||||
}
|
||||
export function finishPlanetologistNoField(fixture: PlanetologistNoFieldFixture, game: Game,
|
||||
actions: PlanetologistNoFieldStep[] = []) {
|
||||
assert.equal(game.turn, fixture.game.turn);
|
||||
return advancePlanetologistNoField(game, state => state.turn === fixture.game.turn &&
|
||||
state.phase === 7 && planetologistNoFieldClean(state), actions);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ void test('scarce native No-Field five materializes only three after both plans
|
||||
assert.equal(player(game, 'richese').forces['wind_pass:14'], 3);
|
||||
game = applyAction(game, staged.actor, { type: 'traitorCall', call: false });
|
||||
game = applyAction(game, staged.target, { type: 'traitorCall', call: false });
|
||||
game = finishAdvancedNativeSkillAftermath(game);
|
||||
assert.equal(game.decision?.kind, 'sukRescue');
|
||||
if (game.decision?.kind !== 'sukRescue') throw new Error('Native real casualties must retain skilled rescue.');
|
||||
const pending = game;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PlanetologistMovement, planetologistMoveDraft } from '../components/planetologist-movement';
|
||||
import { PlanetologistMovementAction, planetologistNoFieldMarker, type PlanetologistMovementActionProps } from '../components/planetologist-movement-action';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { gameTerritories, location, splitLocation } from '../game/board';
|
||||
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
|
||||
const source = 'false_wall_west:16';
|
||||
const sameOrigin = 'false_wall_west:17';
|
||||
const destination = 'polar_sink';
|
||||
|
||||
// Controlled personalized projections supplement the independent native trained
|
||||
// Richese source. No skill, token, army or pending receipt is installed in an
|
||||
// authoritative Game. React renders the stateful parent; only the hook-free
|
||||
// actual submit leaf is invoked directly.
|
||||
function projected(value: NoFieldValue = 0, physical = false, advanced = false): GameView {
|
||||
const game = createGame('PLANETOLOGISTNOFIELDCONTROLS', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
const view = viewGame(game, 'r');
|
||||
view.status = 'playing'; view.phase = 5; view.turn = 2; view.storm = 18; view.active = 'r';
|
||||
view.order = ['r', 'a']; view.movementRemaining = ['r', 'a'];
|
||||
const me = view.players.find(player => player.id === 'r')!;
|
||||
me.moved = 0; me.movesAllowed = 1; me.shipped = true;
|
||||
me.forces = physical ? { [source]: 2, [sameOrigin]: 2 } : {};
|
||||
const tokens = createRicheseNoField(['controlled-zero', 'controlled-three', 'controlled-five']);
|
||||
const marker = deployRicheseNoField(tokens, {
|
||||
tokenId: tokens.tokens.find(token => token.value === value)!.id,
|
||||
controller: me.id, location: splitLocation(source),
|
||||
});
|
||||
const publicMarker = projectRicheseNoField(marker, false);
|
||||
me.noField = publicMarker;
|
||||
view.richeseNoField = {
|
||||
owner: me.id, event: 'controlled-current-no-field-event', public: publicMarker, private: marker,
|
||||
} as NonNullable<GameView['richeseNoField']>;
|
||||
view.leaderSkills = {
|
||||
assignments: [{ owner: me.id, controller: me.id, leader: me.leaders[0].id,
|
||||
skill: 'planetologist', captured: false, faceUp: true }],
|
||||
offer: null, eligibleLeaders: [], battleChoice: null,
|
||||
};
|
||||
return view;
|
||||
}
|
||||
function propsFor(game: GameView, forces: Record<string, number> = {}): PlanetologistMovementActionProps {
|
||||
return { game, mode: 'range', destination, sector: 0, forces, elites: {}, includeNoField: true,
|
||||
busy: false, reasonId: 'controlled-planetologist-reason', act() {} };
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(PlanetologistMovement, { game, destination, sector: 0, busy, act() {} }));
|
||||
}
|
||||
function button(html: string) {
|
||||
const found = html.match(/<button\b(?=[^>]*data-testid="planetologist-move")[^>]*>[\s\S]*?<\/button>/)?.[0];
|
||||
assert.ok(found); return found;
|
||||
}
|
||||
function click(props: PlanetologistMovementActionProps): Action[] {
|
||||
const actions: Action[] = [];
|
||||
PlanetologistMovementAction({ ...props, act(action) { actions.push(action); } }).props.onClick();
|
||||
return actions;
|
||||
}
|
||||
function expected(game: GameView, forces: Record<string, number> = {}): Action {
|
||||
return { type: 'move', planetologist: 'range', forces,
|
||||
noField: game.richeseNoField!.private!.deployed!.tokenId, event: game.richeseNoField!.event,
|
||||
territory: destination, sector: 0 };
|
||||
}
|
||||
|
||||
void test('the owned marker has a usable checkbox origin and persistent provisional one-force range guidance', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = projected(0, false, advanced), before = structuredClone(game);
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /<input\b[^>]*type="checkbox"/);
|
||||
assert.match(html, /Include your concealed No-Field from False Wall West, sector 16/);
|
||||
assert.match(html, /aria-label="Provisional Planetologist No-Field range"/);
|
||||
assert.match(html, /concealed marker moves as one force with Planetologist’s extra range, up to three territories/);
|
||||
assert.match(html, /Move it alone or with physical forces from its own territory/);
|
||||
assert.match(html, /value stays concealed/);
|
||||
assert.match(html, /No direct joint FAQ settles this combination/);
|
||||
assert.match(html, /No physical board forces are available to move/);
|
||||
assert.match(html, /Concealed No-Fields cannot join gather, either Ornithopter card movement or Discovery flight/);
|
||||
assert.doesNotMatch(html, /controlled-zero|controlled-three|controlled-five|Confirm|Acknowledge|countdown/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('marker-only extra range emits the exact existing callback payload and keeps every hidden value concealed', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const game = projected(value), before = structuredClone(game), me = game.players[0];
|
||||
assert.equal(groundMoveRouteDistance(game, me, source, 'polar_sink:0'), 2);
|
||||
assert.equal(botGroundMoveAllowed(game, me, source, 'polar_sink:0', 0), false);
|
||||
assert.equal(botGroundMoveAllowed(game, me, source, 'polar_sink:0', 0, 'range'), true);
|
||||
const draft = planetologistMoveDraft(game, 'range', destination, 0, {}, {}, true);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(draft.action, expected(game));
|
||||
assert.deepEqual(click(propsFor(game)), [expected(game)]);
|
||||
assert.deepEqual(Object.keys(click(propsFor(game))[0]).sort(), ['event', 'forces', 'noField', 'planetologist', 'sector', 'territory', 'type']);
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(game.richeseNoField!.private!.deployed!.tokenId, before.richeseNoField!.private!.deployed!.tokenId);
|
||||
}
|
||||
});
|
||||
|
||||
void test('positive physical groups share the marker territory and preserve exact same-origin sector counts', () => {
|
||||
const game = projected(5, true), before = structuredClone(game);
|
||||
const selections: Record<string, number>[] = [{ [source]: 1 }, { [sameOrigin]: 2 }, { [source]: 2, [sameOrigin]: 1 }];
|
||||
for (const forces of selections) {
|
||||
const draft = planetologistMoveDraft(game, 'range', destination, 0, forces, {}, true);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(draft.action, expected(game, forces));
|
||||
assert.deepEqual(click(propsFor(game, forces)), [expected(game, forces)]);
|
||||
}
|
||||
const html = markup(game);
|
||||
assert.match(html, /False Wall West, sector 16 · 2 forces/);
|
||||
assert.match(html, /False Wall West, sector 17 · 2 forces/);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('the existing six-argument draft keeps physical movement separate from optional marker selection', () => {
|
||||
const game = projected(0, true);
|
||||
const action = { type: 'move', planetologist: 'range', forces: { [source]: 2 }, territory: destination, sector: 0 };
|
||||
assert.deepEqual(planetologistMoveDraft(game, 'range', destination, 0, { [source]: 2 }, {}).action, action);
|
||||
assert.deepEqual(click({ ...propsFor(game, { [source]: 2 }), includeNoField: false }), [action]);
|
||||
assert.equal(planetologistMoveDraft(game, 'range', destination, 0, {}, {}).action, null);
|
||||
assert.deepEqual(click({ ...propsFor(game), includeNoField: false }), []);
|
||||
});
|
||||
|
||||
void test('the marker cannot join gather or carry physical forces from a second territory', () => {
|
||||
const game = projected(0, true);
|
||||
game.players[0].forces['wind_pass:14'] = 2;
|
||||
for (const props of [
|
||||
{ ...propsFor(game), mode: 'gather' as const },
|
||||
{ ...propsFor(game, { [source]: 1, 'wind_pass:14': 1 }), mode: 'gather' as const },
|
||||
propsFor(game, { 'wind_pass:14': 1 }),
|
||||
propsFor(game, { [source]: 1, 'wind_pass:14': 1 }),
|
||||
]) {
|
||||
assert.deepEqual(click(props), []);
|
||||
assert.match(button(renderToStaticMarkup(createElement(PlanetologistMovementAction, props))), /\sdisabled=/);
|
||||
}
|
||||
assert.match(planetologistMoveDraft(game, 'gather', destination, 0, {}, {}, true).blocked!, /cannot join a Planetologist gather/);
|
||||
});
|
||||
|
||||
void test('exact source and destination sectors and valid whole available counts guard actual submission', () => {
|
||||
const game = projected(0, true);
|
||||
const invalid: unknown[] = [null, undefined, [], { [source]: -1 }, { [source]: 0.5 }, { [source]: Number.NaN },
|
||||
{ [source]: Infinity }, { [source]: Number.MAX_SAFE_INTEGER + 1 }, { [source]: '1' }, { [source]: 3 },
|
||||
{ 'false_wall_west:16:extra': 1 }, { 'false_wall_west:15': 1 }, { 'homeworld:richese': 1 }];
|
||||
for (const forces of invalid)
|
||||
assert.deepEqual(click({ ...propsFor(game), forces: forces as Record<string, number> }), []);
|
||||
for (const sector of [-1, 1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.deepEqual(click({ ...propsFor(game), sector }), []);
|
||||
for (const invalidDestination of ['unknown', 'homeworld:richese', 'hidden_mobile_stronghold'])
|
||||
assert.deepEqual(click({ ...propsFor(game), destination: invalidDestination, sector: 0 }), []);
|
||||
});
|
||||
|
||||
void test('unreachable and storm-blocked routes remain unavailable with the boosted marker range', () => {
|
||||
const game = projected();
|
||||
const distant = gameTerritories(game).flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
||||
.find(target => Number.isFinite(groundMoveRouteDistance(game, game.players[0], source, target)) &&
|
||||
groundMoveRouteDistance(game, game.players[0], source, target) > 2);
|
||||
assert.ok(distant, 'The controlled board has a destination beyond extra range');
|
||||
assert.deepEqual(click({ ...propsFor(game), ...splitLocation(distant) }), []);
|
||||
const storm = projected(); storm.storm = 16;
|
||||
assert.deepEqual(click(propsFor(storm)), []);
|
||||
const stormDestination = projected(); stormDestination.storm = 14;
|
||||
assert.deepEqual(click({ ...propsFor(stormDestination), destination: 'wind_pass', sector: 14 }), []);
|
||||
});
|
||||
|
||||
void test('the current Homeworld marker movement refusal remains authoritative without blocking physical-only movement', () => {
|
||||
const game = projected(0, true), reason = 'The occupied Homeworld currently prevents No-Field movement.';
|
||||
game.homeworldMobility.noFieldMovementBlocked = reason;
|
||||
const html = markup(game);
|
||||
assert.ok(html.includes(reason));
|
||||
assert.match(html, /<input\b[^>]*type="checkbox"[^>]*disabled=/);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
assert.deepEqual(click({ ...propsFor(game, { [source]: 1 }), includeNoField: false }), [
|
||||
{ type: 'move', planetologist: 'range', forces: { [source]: 1 }, territory: destination, sector: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('foreign missing stale and malformed marker projections cannot select a private identity', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.richeseNoField = null; },
|
||||
game => { game.richeseNoField!.private = null; },
|
||||
game => { game.richeseNoField!.owner = 'a'; },
|
||||
game => { game.richeseNoField!.private!.deployed = null; },
|
||||
game => { game.richeseNoField!.private!.deployed!.controller = 'a'; },
|
||||
game => { game.richeseNoField!.private!.lastShipped = 'stale-token'; },
|
||||
game => { game.richeseNoField!.private!.tokens = []; },
|
||||
game => { game.richeseNoField!.event = ''; },
|
||||
game => { game.richeseNoField!.public.deployed!.location = { territory: 'wind_pass', sector: 14 }; },
|
||||
game => { game.richeseNoField!.private!.deployed!.location = { territory: 'false_wall_west', sector: 15 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.equal(planetologistNoFieldMarker(game), null);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
assert.doesNotMatch(markup(game), /type="checkbox"|Provisional Planetologist No-Field range/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy phase ownership current responses automatic action and movement allowance guard the real callback', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.status = 'lobby'; },
|
||||
game => { game.phase = 6; },
|
||||
game => { game.active = 'a'; },
|
||||
game => { game.players[0].moved = 1; },
|
||||
game => { game.players[0].movesAllowed = 0; },
|
||||
game => { game.players[0].autopilot = 'Easy'; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
game => { game.response = {} as NonNullable<GameView['response']>; },
|
||||
game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; },
|
||||
game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.truthtrance = {} as NonNullable<GameView['truthtrance']>; },
|
||||
game => { game.nexusTraitors = {
|
||||
offer: null, pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
|
||||
}; },
|
||||
game => { game.ornithopter = { active: { event: 'controlled-flight' } } as NonNullable<GameView['ornithopter']>; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
assert.match(button(renderToStaticMarkup(createElement(PlanetologistMovementAction, propsFor(game)))), /\sdisabled=/);
|
||||
}
|
||||
const game = projected();
|
||||
assert.deepEqual(click({ ...propsFor(game), busy: true }), []);
|
||||
assert.deepEqual(click({ ...propsFor(game), unavailableReason: 'Complete the promised shipment before moving.' }), []);
|
||||
assert.match(button(markup(game, true)), /\sdisabled=/);
|
||||
});
|
||||
|
||||
void test('missing dead captured or foreign-skilled leaders cannot submit or expose marker controls', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.leaderSkills = null; },
|
||||
game => { game.leaderSkills!.assignments[0].owner = 'a'; },
|
||||
game => { game.players[0].leaders[0].dead = true; },
|
||||
game => { game.players[0].leaders[0].capturedBy = 'a'; },
|
||||
game => { game.leaderSkills!.assignments[0].controller = 'a'; },
|
||||
game => { game.leaderSkills!.assignments[0].faceUp = false; },
|
||||
game => { game.me = 'a'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.equal(markup(game), '');
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a callback from an old marker event location or response never sends a newly substituted selection', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.richeseNoField!.event = 'new-event'; },
|
||||
game => {
|
||||
game.richeseNoField!.private!.deployed!.tokenId = 'controlled-three';
|
||||
game.richeseNoField!.private!.lastShipped = 'controlled-three';
|
||||
},
|
||||
game => {
|
||||
game.richeseNoField!.private!.deployed!.location = { territory: 'false_wall_west', sector: 17 };
|
||||
game.richeseNoField!.public.deployed!.location = { territory: 'false_wall_west', sector: 17 };
|
||||
},
|
||||
game => { game.decision = { kind: 'mobileStronghold', player: 'a', placement: false }; },
|
||||
game => { game.players[0].moved = 1; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(), actions: Action[] = [];
|
||||
const leaf = PlanetologistMovementAction({ ...propsFor(game), act(action) { actions.push(action); } });
|
||||
assert.equal(leaf.props.disabled, false);
|
||||
change(game); leaf.props.onClick();
|
||||
assert.deepEqual(actions, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('owner controls and actual submissions never inspect hidden denomination or rival inventories', () => {
|
||||
const game = projected();
|
||||
for (const token of game.richeseNoField!.private!.tokens)
|
||||
Object.defineProperty(token, 'value', { get() { throw Error('Movement read a concealed denomination'); } });
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Movement read unrelated private ${player.id}.${field}`); } });
|
||||
Object.defineProperty(game.players[0], 'noFieldEvent', { get() { throw Error('Movement read raw player custody event'); } });
|
||||
const html = markup(game);
|
||||
assert.match(html, /Provisional Planetologist No-Field range/);
|
||||
assert.deepEqual(click(propsFor(game)), [expected(game)]);
|
||||
const rival = projected(); rival.me = 'a';
|
||||
Object.defineProperty(rival.richeseNoField!, 'private', { get() { throw Error('Rival read private No-Field identity'); } });
|
||||
assert.equal(markup(rival), '');
|
||||
assert.deepEqual(click(propsFor(rival)), []);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { planetologistLeader } from '../game/planetologist-movement';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
actPlanetologistNoField as act, createPlanetologistNoFieldFixture as fixture,
|
||||
finishPlanetologistNoField as finish, movePlanetologistNoField as move,
|
||||
planetologistNoFieldInventory as inventory, planetologistNoFieldPhysical as physical,
|
||||
planetologistNoFieldPlayer as player, type PlanetologistNoFieldFixture,
|
||||
} from './fixture-planetologist-no-field';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function source(f: PlanetologistNoFieldFixture) {
|
||||
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.game.turn, f.companions ? 2 : 1); assert.equal(f.game.phase, 5);
|
||||
assert.equal(f.game.active, f.actor);
|
||||
assert.deepEqual(f.game.expansions, ['choam']);
|
||||
assert.deepEqual(f.game.players.map(seat => seat.faction), ['richese', 'atreides', 'emperor']);
|
||||
assert.ok(f.game.leaderSkills);
|
||||
assert.ok(!f.game.homeworlds && !f.game.nexusCards && !f.game.techTokens &&
|
||||
!f.game.strongholdCards && !f.game.discoveryEnabled && !f.game.ecazTreachery);
|
||||
assert.equal(f.game.phaseOpening ?? null, null); assert.equal(f.game.response ?? null, null);
|
||||
assert.equal(f.game.decision ?? null, null); assert.equal(f.game.pendingTreacheryDiscard ?? null, null);
|
||||
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'leaderSkills' && step.action.enabled === true));
|
||||
assert.ok(f.actions.some(step => step.action.type === 'start'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'leaderSkill' &&
|
||||
step.action.skill === 'planetologist' && step.action.leader === f.trainer));
|
||||
assert.equal(planetologistLeader(f.game, f.actor), f.trainer);
|
||||
assert.equal(player(f.game, f.actor).shipped, true); assert.equal(player(f.game, f.actor).moved, 0);
|
||||
assert.equal(player(f.game, f.actor).forces[f.source] ?? 0, f.companions);
|
||||
assert.equal(territory(f.sourceLocation.territory).type, 'rock');
|
||||
assert.equal(territory(f.target.territory).type, 'rock');
|
||||
assert.equal(player(f.game, f.actor).noField!.deployed!.tokenId, f.token.id);
|
||||
assert.equal(player(f.game, f.actor).noField!.lastShipped, f.token.id);
|
||||
assert.deepEqual(player(f.game, f.actor).noField!.deployed!.location, f.sourceLocation);
|
||||
assert.equal(player(f.game, f.actor).noField!.tokens.find(token => token.id === f.token.id)!.value, f.value);
|
||||
assert.equal(f.shipmentStep.action.type, 'ship');
|
||||
assert.equal(f.shipmentStep.action.noField, f.token.id);
|
||||
assert.equal(f.shipmentStep.action.territory, f.sourceLocation.territory);
|
||||
assert.equal(player(f.beforeShipment, f.actor).spice - player(f.game, f.actor).spice, 2);
|
||||
assert.equal(player(f.beforeShipment, f.actor).reserves, player(f.game, f.actor).reserves);
|
||||
const view = viewGame(f.game, f.actor), me = view.players.find(seat => seat.id === f.actor)!;
|
||||
assert.equal(groundMoveRouteDistance(view, me, f.source, f.targetLocation), 2);
|
||||
assert.equal(groundMoveRouteDistance(view, me, f.source, f.tooFarLocation), 3);
|
||||
assert.equal(botGroundMoveAllowed(view, me, f.source, f.targetLocation, 0), false);
|
||||
assert.equal(botGroundMoveAllowed(view, me, f.source, f.targetLocation, 0, 'range'), true);
|
||||
assert.equal(botGroundMoveAllowed(view, me, f.source, f.tooFarLocation, 0, 'range'), false);
|
||||
if (f.companions) {
|
||||
assert.ok(f.companionShipment);
|
||||
assert.equal(f.companionShipment.before.turn, 1);
|
||||
assert.equal(f.companionShipment.step.action.type, 'ship');
|
||||
assert.equal(f.companionShipment.step.action.amount, f.companions);
|
||||
assert.equal(player(f.companionShipment.after, f.actor).forces[f.source], f.companions);
|
||||
assert.equal(player(f.companionShipment.before, f.actor).reserves - player(f.companionShipment.after, f.actor).reserves, f.companions);
|
||||
assert.ok(f.actions.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
|
||||
} else assert.equal(f.companionShipment, null);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function moved(f: PlanetologistNoFieldFixture, game: Game) {
|
||||
const before = player(f.game, f.actor), after = player(game, f.actor);
|
||||
assert.deepEqual(after.noField!.tokens, f.originalNoField.tokens);
|
||||
assert.equal(after.noField!.lastShipped, f.originalNoField.lastShipped);
|
||||
assert.deepEqual(after.noField!.deployed, { ...f.originalNoField.deployed!, location: f.target });
|
||||
assert.notEqual(after.noFieldEvent, f.originalEvent);
|
||||
assert.equal(after.moved, before.moved + 1); assert.equal(after.shipped, true);
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
|
||||
assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.forces[f.source] ?? 0, 0);
|
||||
assert.equal(after.forces[f.targetLocation] ?? 0, f.companions);
|
||||
assert.equal(presenceAt(after, f.target.territory), 1 + f.companions,
|
||||
'Every concealed denomination remains one marker beside only the actual selected counters');
|
||||
assert.equal(planetologistLeader(game, f.actor), f.trainer);
|
||||
assert.deepEqual(game.leaderSkills, f.game.leaderSkills);
|
||||
assert.deepEqual(after.leaders, before.leaders);
|
||||
assert.deepEqual(after.hand, before.hand);
|
||||
const observed = viewGame(game, f.observer).richeseNoField!;
|
||||
assert.equal(observed.private, null);
|
||||
assert.deepEqual(observed.public.deployed, { controller: f.actor, location: f.target, effectiveForces: 1 });
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) for (const companions of [0, 1] as const) {
|
||||
void test(`public ${advanced ? 'Advanced' : 'Basic'} concealed${value} ${companions ? 'with a genuinely shipped companion' : 'marker-only'} moves distance two through its original Planetologist`, () => {
|
||||
const f = fixture({ advanced, value, companions }); source(f);
|
||||
rejected(f.game, f.actor, f.ordinary);
|
||||
rejected(f.game, f.actor, f.tooFarAction);
|
||||
const before = structuredClone(f.game);
|
||||
let game = move(f);
|
||||
assert.deepEqual(f.game, before);
|
||||
moved(f, game);
|
||||
if (!companions) assert.deepEqual(physical(game), f.originalPhysical,
|
||||
'Marker-only movement does not materialize any physical force, change a wallet or consume a card');
|
||||
rejected(game, f.actor, f.action);
|
||||
const relocated = structuredClone(player(game, f.actor).noField), newEvent = player(game, f.actor).noFieldEvent;
|
||||
const continuation: { actor: string; action: Action }[] = [];
|
||||
game = finish(f, game, continuation);
|
||||
assert.equal(game.turn, f.game.turn); assert.equal(game.phase, 7);
|
||||
assert.ok(continuation.some(step => step.actor === f.actor && step.action.type === 'endMovement'));
|
||||
moved(f, game);
|
||||
assert.deepEqual(player(game, f.actor).noField, relocated);
|
||||
assert.equal(player(game, f.actor).noFieldEvent, newEvent);
|
||||
});
|
||||
}
|
||||
|
||||
void test('original Planetologist marker movement rejects foreign or stale selections and keeps unsupported route combinations fenced', () => {
|
||||
const f = fixture(); source(f);
|
||||
rejected(f.game, f.opponent, f.action);
|
||||
rejected(f.game, f.actor, { ...f.action, event: 'stale-native-marker-event' });
|
||||
const other = player(f.game, f.actor).noField!.tokens.find(token => token.id !== f.token.id)!;
|
||||
rejected(f.game, f.actor, { ...f.action, noField: other.id });
|
||||
rejected(f.game, f.actor, { ...f.action, forces: { 'arrakeen:10': 1 } });
|
||||
rejected(f.game, f.actor, { ...f.action, planetologist: 'gather' });
|
||||
rejected(f.game, f.actor, { ...f.action, planetologist: { leader: f.trainer, mode: 'range' } });
|
||||
rejected(f.game, f.actor, { ...f.action, discoveryFlight: { event: 'foreign-flight' } });
|
||||
rejected(f.game, f.actor, { ...f.action, ornithopterEvent: 'foreign-card-movement' });
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
assert.deepEqual(player(f.game, f.actor).noField, f.originalNoField);
|
||||
assert.equal(player(f.game, f.actor).noFieldEvent, f.originalEvent);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
});
|
||||
|
||||
void test('controlled eligibility supplements deny No-Field extra range after the native trainer is dead or captured', () => {
|
||||
const f = fixture(); source(f);
|
||||
// These deliberately controlled eligibility inputs do not claim a played
|
||||
// death/capture chronology; the public positive sources above remain intact.
|
||||
for (const state of ['dead', 'captured'] as const) {
|
||||
const controlled = structuredClone(f.game), leader = player(controlled, f.actor).leaders.find(disc => disc.id === f.trainer)!;
|
||||
if (state === 'dead') leader.dead = true;
|
||||
else leader.capturedBy = f.observer;
|
||||
assert.equal(planetologistLeader(controlled, f.actor), null);
|
||||
rejected(controlled, f.actor, f.action);
|
||||
}
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
});
|
||||
|
||||
void test('all four minimal policies offer a legal boosted original No-Field move', () => {
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
|
||||
const f = fixture({ advanced, value }); source(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, f.actor);
|
||||
view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const candidates = botActions(view).filter(action => action.type === 'move' &&
|
||||
action.planetologist === 'range' && action.noField === f.token.id);
|
||||
assert.ok(candidates.length, `${difficulty} offers an actual boosted concealed${value} candidate`);
|
||||
const action = candidates[0];
|
||||
assert.equal(typeof action.territory, 'string');
|
||||
assert.equal(typeof action.sector, 'number');
|
||||
const destination = { territory: action.territory as string, sector: action.sector as number };
|
||||
assert.deepEqual(action.forces, {});
|
||||
assert.equal(action.event, f.originalEvent);
|
||||
const me = view.players.find(seat => seat.id === f.actor)!;
|
||||
assert.equal(groundMoveRouteDistance(view, me, f.source, `${destination.territory}:${destination.sector}`), 2);
|
||||
const game = act(f.game, f.actor, action), after = player(game, f.actor);
|
||||
assert.equal(after.noField!.deployed!.tokenId, f.token.id);
|
||||
assert.deepEqual(after.noField!.deployed!.location, destination);
|
||||
assert.equal(after.noField!.lastShipped, f.token.id);
|
||||
assert.equal(after.reserves, player(f.game, f.actor).reserves);
|
||||
assert.equal(after.spice, player(f.game, f.actor).spice);
|
||||
assert.deepEqual(after.forces, player(f.game, f.actor).forces);
|
||||
assert.equal(after.moved, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user