Support concealed No-Field Planetologist gathering
This commit is contained in:
1 parent
f6e6671ac5
commit
90c50736f2
14 files changed
+817
-42
No files matched your search
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { gameTerritories, location, splitLocation } from '../game/board';
|
||||
import { 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 } from './fixture-selected-deck-skills';
|
||||
import {
|
||||
actPlanetologistNoField, advancePlanetologistNoField, planetologistNoFieldClean,
|
||||
planetologistNoFieldInventory, planetologistNoFieldPhysical, planetologistNoFieldPlayer,
|
||||
type PlanetologistNoFieldStep,
|
||||
} from './fixture-planetologist-no-field';
|
||||
|
||||
export type PlanetologistNoFieldGatherStep = PlanetologistNoFieldStep;
|
||||
export const actPlanetologistNoFieldGather = actPlanetologistNoField;
|
||||
export const advancePlanetologistNoFieldGather = advancePlanetologistNoField;
|
||||
export const planetologistNoFieldGatherClean = planetologistNoFieldClean;
|
||||
export const planetologistNoFieldGatherInventory = planetologistNoFieldInventory;
|
||||
export const planetologistNoFieldGatherPhysical = planetologistNoFieldPhysical;
|
||||
export const planetologistNoFieldGatherPlayer = planetologistNoFieldPlayer;
|
||||
|
||||
function gatherRoutes(game: Game, actor: string, physicalSource: string, markerSource: string) {
|
||||
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
|
||||
const fromTerritories = [physicalSource, markerSource].map(source => splitLocation(source).territory);
|
||||
const emptyRocks = gameTerritories(game).filter(place => place.type === 'rock' &&
|
||||
!fromTerritories.includes(place.id) && !game.players.some(player => presenceAt(player, place.id)))
|
||||
.flatMap(place => place.sectors.map(sector => location(place.id, sector)));
|
||||
const targetLocation = emptyRocks.find(target => [physicalSource, markerSource].every(source =>
|
||||
groundMoveRouteDistance(view, me, source, target) === 1 && botGroundMoveAllowed(view, me, source, target, 0, 'gather')));
|
||||
const tooFarLocation = emptyRocks.find(target => [physicalSource, markerSource].every(source =>
|
||||
botGroundMoveAllowed(view, me, source, target, 0, 'range')) && [physicalSource, markerSource].some(source =>
|
||||
groundMoveRouteDistance(view, me, source, target) === 2));
|
||||
return targetLocation && tooFarLocation ? { targetLocation, tooFarLocation } : null;
|
||||
}
|
||||
|
||||
/** Genuine public R/A/E +choam/Skills. First-turn paid physical shipment and
|
||||
* optional ordinary split precede a real turn-two marker shipment to a second
|
||||
* origin. No played cards, counters, wallets, markers or phases are installed. */
|
||||
export function createPlanetologistNoFieldGatherFixture(options: {
|
||||
advanced?: boolean; value?: NoFieldValue; withMarkerCompanion?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, value = options.value ?? 5;
|
||||
const withMarkerCompanion = options.withMarkerCompanion ?? false;
|
||||
const setup = createSelectedDeckSkillsSetup({ advanced, factions: ['richese', 'atreides', 'emperor'],
|
||||
expansions: ['choam'], skill: 'planetologist' });
|
||||
const actor = setup.owner, actions: PlanetologistNoFieldGatherStep[] = [...setup.actions];
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const act = (owner: string, action: Action) => { game = actPlanetologistNoFieldGather(game, owner, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advancePlanetologistNoFieldGather(game, until, actions); };
|
||||
const originalInventory = planetologistNoFieldGatherInventory(game);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === actor && planetologistNoFieldGatherClean(state));
|
||||
const view = viewGame(game, actor), me = view.players.find(player => player.id === actor)!;
|
||||
const rocks = gameTerritories(game).filter(place => place.type === 'rock' &&
|
||||
!game.players.some(player => presenceAt(player, place.id)))
|
||||
.flatMap(place => place.sectors.filter(sector => sector >= 7 && sector <= 16 && sector !== game.storm)
|
||||
.map(sector => location(place.id, sector)));
|
||||
let geometry: { physicalSource: string; markerSource: string } | null = null;
|
||||
for (const physicalSource of rocks) {
|
||||
for (const markerSource of rocks) {
|
||||
if (splitLocation(physicalSource).territory === splitLocation(markerSource).territory ||
|
||||
groundMoveRouteDistance(view, me, physicalSource, markerSource) !== 1 ||
|
||||
!botGroundMoveAllowed(view, me, physicalSource, markerSource, 0) ||
|
||||
!gatherRoutes(game, actor, physicalSource, markerSource)) continue;
|
||||
geometry = { physicalSource, markerSource }; break;
|
||||
}
|
||||
if (geometry) break;
|
||||
}
|
||||
assert.ok(geometry, 'Choose two empty adjacent rock origins with a shared normal-range rock destination');
|
||||
const { physicalSource, markerSource } = geometry;
|
||||
const physicalSourceLocation = splitLocation(physicalSource), sourceLocation = splitLocation(markerSource);
|
||||
const amount = withMarkerCompanion ? 2 : 1;
|
||||
const beforePhysicalShipment = structuredClone(game), physicalShipmentStep: PlanetologistNoFieldGatherStep = { actor,
|
||||
action: { type: 'ship', ...physicalSourceLocation, amount, elite: 0, allyPayment: 0 } };
|
||||
act(actor, physicalShipmentStep.action); reach(planetologistNoFieldGatherClean);
|
||||
const physicalShipment = { before: beforePhysicalShipment, step: physicalShipmentStep, after: structuredClone(game) };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(beforePhysicalShipment, actor).spice -
|
||||
planetologistNoFieldGatherPlayer(game, actor).spice, amount * 2);
|
||||
let splitMovement: { before: Game; step: PlanetologistNoFieldGatherStep; after: Game } | null = null;
|
||||
if (withMarkerCompanion) {
|
||||
const before = structuredClone(game), step: PlanetologistNoFieldGatherStep = { actor,
|
||||
action: { type: 'move', from: physicalSource, amount: 1, elite: 0, ...sourceLocation } };
|
||||
act(actor, step.action); reach(planetologistNoFieldGatherClean);
|
||||
splitMovement = { before, step, after: structuredClone(game) };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).forces[physicalSource], 1);
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).forces[markerSource], 1);
|
||||
}
|
||||
const afterFirstPreparation = structuredClone(game);
|
||||
let charity: { before: Game; step: PlanetologistNoFieldGatherStep; after: Game } | null = null;
|
||||
if (withMarkerCompanion) {
|
||||
reach(state => state.turn === 2 && state.phase === 2 && planetologistNoFieldGatherClean(state));
|
||||
const before = structuredClone(game), step: PlanetologistNoFieldGatherStep = { actor, action: { type: 'charity' } };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).spice, 1);
|
||||
act(actor, step.action); reach(planetologistNoFieldGatherClean);
|
||||
charity = { before, step, after: structuredClone(game) };
|
||||
assert.equal(planetologistNoFieldGatherPlayer(game, actor).spice, 2);
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && planetologistNoFieldGatherClean(state));
|
||||
const current = planetologistNoFieldGatherPlayer(game, actor);
|
||||
assert.equal(current.forces[physicalSource], 1);
|
||||
assert.equal(current.forces[markerSource] ?? 0, Number(withMarkerCompanion));
|
||||
assert.equal(current.moved, 0); assert.equal(current.shipped, false);
|
||||
if (withMarkerCompanion) {
|
||||
assert.equal(planetologistNoFieldGatherPlayer(afterFirstPreparation, actor).spice, 1);
|
||||
assert.equal(current.spice, 2, 'Real next-turn charity funds the second paid shipment');
|
||||
}
|
||||
const token = current.noField!.tokens.find(candidate => candidate.value === value); assert.ok(token);
|
||||
assert.equal(current.noField!.deployed, null);
|
||||
const beforeShipment = structuredClone(game), shipmentStep: PlanetologistNoFieldGatherStep = { actor,
|
||||
action: { type: 'ship', noField: token.id, event: current.noFieldEvent, ...sourceLocation } };
|
||||
act(actor, shipmentStep.action); reach(planetologistNoFieldGatherClean);
|
||||
const afterShipment = structuredClone(game), routes = gatherRoutes(game, actor, physicalSource, markerSource);
|
||||
assert.ok(routes, 'The real second-turn board retains both normal gather routes and the non-stacking negative');
|
||||
assert.equal(planetologistNoFieldGatherPlayer(beforeShipment, actor).spice - planetologistNoFieldGatherPlayer(game, actor).spice, 2);
|
||||
assert.equal(planetologistNoFieldGatherPlayer(beforeShipment, actor).reserves, planetologistNoFieldGatherPlayer(game, actor).reserves);
|
||||
const trainer = planetologistLeader(game, actor); assert.ok(trainer);
|
||||
const forces: Record<string, number> = { [physicalSource]: 1, ...(withMarkerCompanion ? { [markerSource]: 1 } : {}) };
|
||||
const target = splitLocation(routes.targetLocation), tooFar = splitLocation(routes.tooFarLocation);
|
||||
const action: Action = { type: 'move', planetologist: 'gather', forces,
|
||||
noField: token.id, event: planetologistNoFieldGatherPlayer(game, actor).noFieldEvent, ...target };
|
||||
const tooFarAction: Action = { ...action, ...tooFar };
|
||||
assert.deepEqual(planetologistNoFieldGatherInventory(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: markerSource, markerSource, physicalSource,
|
||||
sourceLocation, physicalSourceLocation, target, targetLocation: routes.targetLocation,
|
||||
tooFar, tooFarLocation: routes.tooFarLocation, tooFarAction, action, forces,
|
||||
withMarkerCompanion, companions: Number(withMarkerCompanion), amount,
|
||||
physicalShipment, normalShipment: physicalShipment, splitMovement, afterFirstPreparation, charity, charityClaim: charity,
|
||||
beforeShipment, shipmentStep, afterShipment, markerShipment: { before: beforeShipment, step: shipmentStep, after: afterShipment },
|
||||
originalInventory, originalPhysical: planetologistNoFieldGatherPhysical(game),
|
||||
originalNoField: structuredClone(planetologistNoFieldGatherPlayer(game, actor).noField!),
|
||||
originalEvent: planetologistNoFieldGatherPlayer(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 setup and actual private Planetologist training. Only the existing original unopened skill/scalar lotteries are selected; no played card, force, wallet, leader, marker, clock or receipt is installed. Advanced remains preview evidence.',
|
||||
withMarkerCompanion ? 'Actual turn-one two-force shipment costs4. One real ordinary range-one movement leaves one physical counter at each adjacent rock origin. Native turn progression stops at actual turn-two Charity, where Richese explicitly claims its original charity action to raise wallet1 to2, funding the actual concealed-marker shipment beside its companion.'
|
||||
: 'Actual turn-one one-force shipment costs2 and remains at its rock origin. Native end-turn and second Storm/Spice/Charity/Bidding/Revival controls precede the actual turn-two concealed0/3/5 shipment to a different empty rock origin for2.',
|
||||
'Both real origins reach one shared empty rock destination using ordinary range1. Stop before the actual gather action; token/event, all selected physical counters, one current movement and original Collection continuation remain caller-owned outcomes. A separate range2 destination is illegal for gather because the +1 branch does not stack.',
|
||||
] };
|
||||
}
|
||||
export type PlanetologistNoFieldGatherFixture = ReturnType<typeof createPlanetologistNoFieldGatherFixture>;
|
||||
export function movePlanetologistNoFieldGather(fixture: PlanetologistNoFieldGatherFixture,
|
||||
game = structuredClone(fixture.game), actions: PlanetologistNoFieldGatherStep[] = []) {
|
||||
return actPlanetologistNoFieldGather(game, fixture.actor, fixture.action, actions);
|
||||
}
|
||||
export function finishPlanetologistNoFieldGather(fixture: PlanetologistNoFieldGatherFixture, game: Game,
|
||||
actions: PlanetologistNoFieldGatherStep[] = []) {
|
||||
assert.equal(game.turn, fixture.game.turn);
|
||||
return advancePlanetologistNoFieldGather(game, state => state.turn === fixture.game.turn &&
|
||||
state.phase === 7 && planetologistNoFieldGatherClean(state), actions);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ void test('the owned marker has a usable checkbox origin and persistent provisio
|
||||
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.match(html, /Concealed No-Fields cannot join either Ornithopter card movement or Discovery flight/);
|
||||
assert.doesNotMatch(html, /controlled-zero|controlled-three|controlled-five|Confirm|Acknowledge|countdown/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
@@ -125,7 +125,7 @@ void test('the existing six-argument draft keeps physical movement separate from
|
||||
assert.deepEqual(click({ ...propsFor(game), includeNoField: false }), []);
|
||||
});
|
||||
|
||||
void test('the marker cannot join gather or carry physical forces from a second territory', () => {
|
||||
void test('marker gather requires two origins and ordinary range while extra range excludes a second territory', () => {
|
||||
const game = projected(0, true);
|
||||
game.players[0].forces['wind_pass:14'] = 2;
|
||||
for (const props of [
|
||||
@@ -137,7 +137,7 @@ void test('the marker cannot join gather or carry physical forces from a second
|
||||
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/);
|
||||
assert.match(planetologistMoveDraft(game, 'gather', destination, 0, {}, {}, true).blocked!, /exactly two different territories/);
|
||||
});
|
||||
|
||||
void test('exact source and destination sectors and valid whole available counts guard actual submission', () => {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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, planetologistMovementBlocked, 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 { splitLocation } from '../game/board';
|
||||
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
|
||||
|
||||
const markerSource = 'wind_pass:14';
|
||||
const companionSource = 'wind_pass:15';
|
||||
const secondSource = 'wind_pass_north:17';
|
||||
const thirdSource = 'false_wall_west:16';
|
||||
|
||||
// Controlled entitled projections supplement the separate native gather source.
|
||||
// No trainer, army, marker or pending receipt is installed in a Game. React
|
||||
// renders the stateful parent; direct calls enter only the actual submit leaf.
|
||||
function projected(value: NoFieldValue = 0, advanced = false): GameView {
|
||||
const game = createGame('PLANETOLOGISTNOFIELDGATHERCONTROLS', 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[0];
|
||||
me.moved = 0; me.movesAllowed = 1; me.shipped = true;
|
||||
me.forces = { [companionSource]: 2, [secondSource]: 3, [thirdSource]: 1 };
|
||||
const tokens = createRicheseNoField(['gather-zero', 'gather-three', 'gather-five']);
|
||||
const marker = deployRicheseNoField(tokens, {
|
||||
tokenId: tokens.tokens.find(token => token.value === value)!.id,
|
||||
controller: me.id, location: splitLocation(markerSource),
|
||||
});
|
||||
const publicMarker = projectRicheseNoField(marker, false);
|
||||
me.noField = publicMarker;
|
||||
view.richeseNoField = {
|
||||
owner: me.id, event: 'controlled-current-gather-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> = { [secondSource]: 1 }): PlanetologistMovementActionProps {
|
||||
return { game, mode: 'gather', destination: 'polar_sink', sector: 0, forces, elites: {},
|
||||
includeNoField: true, busy: false, reasonId: 'controlled-gather-reason', act() {} };
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(PlanetologistMovement, { game, destination: 'polar_sink', 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: 'gather', forces,
|
||||
noField: game.richeseNoField!.private!.deployed!.tokenId, event: game.richeseNoField!.event,
|
||||
territory: 'polar_sink', sector: 0 };
|
||||
}
|
||||
|
||||
void test('owned marker controls explain the provisional two-origin gather and ordinary range without hiding the checkbox', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const game = projected(value), before = structuredClone(game);
|
||||
const html = markup(game);
|
||||
const checkbox = html.match(/<input\b[^>]*type="checkbox"[^>]*>/)?.[0];
|
||||
assert.ok(checkbox); assert.doesNotMatch(checkbox, /\sdisabled=/);
|
||||
assert.match(html, /Include your concealed No-Field from Wind Pass, sector 14/);
|
||||
assert.match(html, /aria-label="Provisional Planetologist No-Field gather"/);
|
||||
assert.match(html, /Provisional gather composition: the concealed marker supplies one of exactly two origin territories/);
|
||||
assert.match(html, /alone or with physical forces there/);
|
||||
assert.match(html, /Select physical forces from one other territory/);
|
||||
assert.match(html, /Every origin uses its normal range; gather adds no extra range/);
|
||||
assert.match(html, /Its value stays concealed/);
|
||||
assert.doesNotMatch(html, /gather-zero|gather-three|gather-five|cannot join gather|Confirm|Acknowledge/);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual gather callbacks accept a marker-only first origin and exact second-origin physical forces including concealed zero', () => {
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) {
|
||||
const game = projected(value, advanced), before = structuredClone(game);
|
||||
assert.equal(game.players[0].forces[markerSource], undefined);
|
||||
const forces = { [secondSource]: 2 };
|
||||
const draft = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, forces, {}, true);
|
||||
assert.equal(draft.blocked, null);
|
||||
assert.deepEqual(draft.action, expected(game, forces));
|
||||
assert.deepEqual(click(propsFor(game, forces)), [expected(game, forces)]);
|
||||
assert.deepEqual(Object.keys(click(propsFor(game, forces))[0]).sort(), ['event', 'forces', 'noField', 'planetologist', 'sector', 'territory', 'type']);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('same-territory physical companions stay in the marker origin while the second physical origin remains exact', () => {
|
||||
const game = projected(5), before = structuredClone(game);
|
||||
const forces = { [companionSource]: 2, [secondSource]: 3 };
|
||||
assert.deepEqual(click(propsFor(game, forces)), [expected(game, forces)]);
|
||||
const withZeroThird = { ...forces, [thirdSource]: 0 };
|
||||
assert.deepEqual(click(propsFor(game, withZeroThird)), [expected(game, forces)]);
|
||||
const physicalOnly = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, forces, {});
|
||||
assert.equal(physicalOnly.blocked, null);
|
||||
assert.deepEqual(physicalOnly.action, { type: 'move', planetologist: 'gather', forces, territory: 'polar_sink', sector: 0 });
|
||||
assert.deepEqual(click({ ...propsFor(game, forces), includeNoField: false }), [physicalOnly.action]);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('changing movement modes can retain the same selected marker without altering its event or source', () => {
|
||||
const game = projected(), before = structuredClone(game);
|
||||
const range = { ...propsFor(game, { [companionSource]: 1 }), mode: 'range' as const };
|
||||
const first = click(range);
|
||||
assert.equal(first.length, 1);
|
||||
const gather = click({ ...range, mode: 'gather', forces: { [companionSource]: 1, [secondSource]: 1 } });
|
||||
assert.deepEqual(gather, [expected(game, { [companionSource]: 1, [secondSource]: 1 })]);
|
||||
assert.deepEqual(click(range), first);
|
||||
assert.equal(gather[0].noField, first[0].noField);
|
||||
assert.equal(gather[0].event, first[0].event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('gather requires exactly two territories including the marker and rejects missing or third origins', () => {
|
||||
const game = projected();
|
||||
const invalid: Record<string, number>[] = [{}, { [companionSource]: 1 }, { [secondSource]: 0 },
|
||||
{ [secondSource]: 1, [thirdSource]: 1 }, { [companionSource]: 1, [secondSource]: 1, [thirdSource]: 1 }];
|
||||
for (const forces of invalid) {
|
||||
const draft = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, forces, {}, true);
|
||||
assert.equal(draft.action, null);
|
||||
assert.match(draft.blocked!, /exactly two different territories/);
|
||||
assert.deepEqual(click(propsFor(game, forces)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('each origin keeps ordinary range and a distance-two marker receives no gather range bonus', () => {
|
||||
const game = projected(), me = game.players[0];
|
||||
for (const source of [markerSource, secondSource]) {
|
||||
assert.equal(groundMoveRouteDistance(game, me, source, 'polar_sink:0'), 1);
|
||||
assert.equal(botGroundMoveAllowed(game, me, source, 'polar_sink:0', 0, 'gather'), true);
|
||||
}
|
||||
assert.equal(groundMoveRouteDistance(game, me, thirdSource, 'polar_sink:0'), 2);
|
||||
assert.equal(botGroundMoveAllowed(game, me, thirdSource, 'polar_sink:0', 0, 'range'), true);
|
||||
assert.equal(botGroundMoveAllowed(game, me, thirdSource, 'polar_sink:0', 0, 'gather'), false);
|
||||
assert.deepEqual(click(propsFor(game, { [thirdSource]: 1 })), []);
|
||||
game.richeseNoField!.private!.deployed!.location = splitLocation(thirdSource);
|
||||
game.richeseNoField!.public.deployed!.location = splitLocation(thirdSource);
|
||||
const gather = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, { [secondSource]: 1 }, {}, true);
|
||||
assert.match(gather.blocked!, /normal range/);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
assert.equal(click({ ...propsFor(game, {}), mode: 'range' }).length, 1);
|
||||
});
|
||||
|
||||
void test('whole available force counts exact locations and selected elite counts guard gather submission', () => {
|
||||
const game = projected();
|
||||
const invalid: unknown[] = [null, undefined, [], { [secondSource]: -1 }, { [secondSource]: 0.5 },
|
||||
{ [secondSource]: Number.NaN }, { [secondSource]: Infinity }, { [secondSource]: '1' }, { [secondSource]: 4 },
|
||||
{ 'wind_pass_north:17:extra': 1 }, { 'wind_pass_north:16': 1 }, { 'homeworld:richese': 1 }];
|
||||
for (const forces of invalid)
|
||||
assert.deepEqual(click({ ...propsFor(game), forces: forces as Record<string, number> }), []);
|
||||
assert.deepEqual(click({ ...propsFor(game), elites: { [secondSource]: 1 } }), []);
|
||||
for (const sector of [-1, 1, 0.5, Number.NaN])
|
||||
assert.deepEqual(click({ ...propsFor(game), sector }), []);
|
||||
const storm = projected(); storm.storm = 14;
|
||||
assert.deepEqual(click(propsFor(storm)), []);
|
||||
});
|
||||
|
||||
void test('empty optional reason cannot bypass phase response automatic ownership or remaining-movement guards', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.phase = 6; },
|
||||
game => { game.active = 'a'; },
|
||||
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.automaticContinuationPending = true; },
|
||||
game => { game.players[0].autopilot = 'Easy'; },
|
||||
game => { game.players[0].moved = 1; },
|
||||
game => { game.ornithopter = { active: { event: 'controlled-flight' } } as NonNullable<GameView['ornithopter']>; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
const props = { ...propsFor(game), unavailableReason: '' };
|
||||
const draft = planetologistMoveDraft(game, props.mode, props.destination, props.sector, props.forces, props.elites, true);
|
||||
assert.ok(planetologistMovementBlocked(game, draft, ''));
|
||||
assert.deepEqual(click(props), []);
|
||||
assert.match(button(renderToStaticMarkup(createElement(PlanetologistMovementAction, props))), /\sdisabled=/);
|
||||
}
|
||||
const game = projected();
|
||||
assert.deepEqual(click({ ...propsFor(game), unavailableReason: '' }), [expected(game, { [secondSource]: 1 })]);
|
||||
assert.deepEqual(click({ ...propsFor(game), busy: true }), []);
|
||||
assert.deepEqual(click({ ...propsFor(game), unavailableReason: 'Complete the promised shipment before moving.' }), []);
|
||||
});
|
||||
|
||||
void test('current trainer and owned current marker remain required for gather', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.leaderSkills = null; },
|
||||
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.richeseNoField!.private = null; },
|
||||
game => { game.richeseNoField!.owner = 'a'; },
|
||||
game => { game.richeseNoField!.private!.deployed!.controller = 'a'; },
|
||||
game => { game.richeseNoField!.private!.lastShipped = 'stale-token'; },
|
||||
game => { game.me = 'a'; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected(); change(game);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale marker events sources trainers and current force availability guard the already-rendered callback', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.richeseNoField!.event = 'new-gather-event'; },
|
||||
game => {
|
||||
game.richeseNoField!.private!.deployed!.tokenId = 'gather-three';
|
||||
game.richeseNoField!.private!.lastShipped = 'gather-three';
|
||||
},
|
||||
game => {
|
||||
game.richeseNoField!.private!.deployed!.location = splitLocation(companionSource);
|
||||
game.richeseNoField!.public.deployed!.location = splitLocation(companionSource);
|
||||
},
|
||||
game => { game.players[0].leaders[0].dead = true; },
|
||||
game => { game.players[0].forces[secondSource] = 0; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
];
|
||||
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('Homeworld marker movement denial remains independent of ordinary physical gather', () => {
|
||||
const game = projected(), reason = 'The occupied Homeworld currently prevents No-Field movement.';
|
||||
game.homeworldMobility.noFieldMovementBlocked = reason;
|
||||
const draft = planetologistMoveDraft(game, 'gather', 'polar_sink', 0, { [secondSource]: 1 }, {}, true);
|
||||
assert.equal(draft.blocked, reason);
|
||||
assert.deepEqual(click(propsFor(game)), []);
|
||||
const forces = { [companionSource]: 1, [secondSource]: 1 };
|
||||
assert.deepEqual(click({ ...propsFor(game, forces), includeNoField: false }), [
|
||||
{ type: 'move', planetologist: 'gather', forces, territory: 'polar_sink', sector: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('gather controls never inspect denominations rival hands raw token events or reserve counts', () => {
|
||||
const game = projected();
|
||||
for (const token of game.richeseNoField!.private!.tokens)
|
||||
Object.defineProperty(token, 'value', { get() { throw Error('Gather read a concealed denomination'); } });
|
||||
for (const player of game.players) for (const field of ['hand', 'traitors', 'faceDancers', 'spice', 'reserves', 'noFieldEvent'])
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Gather read unrelated ${player.id}.${field}`); } });
|
||||
assert.match(markup(game), /Provisional Planetologist No-Field gather/);
|
||||
assert.deepEqual(click(propsFor(game)), [expected(game, { [secondSource]: 1 })]);
|
||||
const rival = projected(); rival.richeseNoField!.owner = 'a';
|
||||
Object.defineProperty(rival.richeseNoField!, 'private', { get() { throw Error('Gather read a foreign token identity'); } });
|
||||
assert.deepEqual(click(propsFor(rival)), []);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { location, splitLocation, 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 {
|
||||
actPlanetologistNoFieldGather as act, createPlanetologistNoFieldGatherFixture as fixture,
|
||||
finishPlanetologistNoFieldGather as finish, movePlanetologistNoFieldGather as move,
|
||||
planetologistNoFieldGatherInventory as inventory, planetologistNoFieldGatherPhysical as physical,
|
||||
planetologistNoFieldGatherPlayer as player, type PlanetologistNoFieldGatherFixture,
|
||||
} from './fixture-planetologist-no-field-gather';
|
||||
|
||||
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: PlanetologistNoFieldGatherFixture) {
|
||||
assert.equal(f.game.status, 'playing'); assert.equal(f.game.advanced, f.advanced);
|
||||
assert.equal(f.game.turn, 2); 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(f.physicalShipment.before.turn, 1);
|
||||
assert.equal(f.physicalShipment.step.action.type, 'ship');
|
||||
assert.equal(f.physicalShipment.step.action.amount, f.amount);
|
||||
assert.equal(player(f.physicalShipment.before, f.actor).spice - player(f.physicalShipment.after, f.actor).spice, f.amount * 2);
|
||||
assert.equal(player(f.physicalShipment.before, f.actor).reserves - player(f.physicalShipment.after, f.actor).reserves, f.amount);
|
||||
assert.equal(player(f.game, f.actor).forces[f.physicalSource], 1);
|
||||
assert.equal(player(f.game, f.actor).forces[f.markerSource] ?? 0, Number(f.withMarkerCompanion));
|
||||
assert.notEqual(f.sourceLocation.territory, f.physicalSourceLocation.territory);
|
||||
assert.equal(territory(f.sourceLocation.territory).type, 'rock');
|
||||
assert.equal(territory(f.physicalSourceLocation.territory).type, 'rock');
|
||||
assert.equal(territory(f.target.territory).type, 'rock');
|
||||
assert.equal(f.beforeShipment.turn, 2);
|
||||
assert.equal(f.shipmentStep.action.type, 'ship'); assert.equal(f.shipmentStep.action.noField, f.token.id);
|
||||
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);
|
||||
assert.equal(player(f.game, f.actor).reserves, 20 - f.amount);
|
||||
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(Object.hasOwn(f.action, 'origins'), false, 'The server normalizes marker-first origins from actual pieces');
|
||||
const view = viewGame(f.game, f.actor), me = view.players.find(seat => seat.id === f.actor)!;
|
||||
for (const from of [f.markerSource, f.physicalSource]) {
|
||||
assert.equal(groundMoveRouteDistance(view, me, from, f.targetLocation), 1);
|
||||
assert.equal(botGroundMoveAllowed(view, me, from, f.targetLocation, 0, 'gather'), true);
|
||||
assert.equal(botGroundMoveAllowed(view, me, from, f.tooFarLocation, 0, 'range'), true);
|
||||
}
|
||||
assert.ok([f.markerSource, f.physicalSource].some(from =>
|
||||
groundMoveRouteDistance(view, me, from, f.tooFarLocation) === 2 &&
|
||||
!botGroundMoveAllowed(view, me, from, f.tooFarLocation, 0, 'gather')));
|
||||
if (f.withMarkerCompanion) {
|
||||
assert.ok(f.splitMovement);
|
||||
assert.equal(f.splitMovement.before.turn, 1);
|
||||
assert.equal(f.splitMovement.step.action.type, 'move');
|
||||
assert.equal(f.splitMovement.step.action.planetologist, undefined);
|
||||
assert.equal(player(f.splitMovement.after, f.actor).moved, 1);
|
||||
assert.equal(player(f.splitMovement.after, f.actor).forces[f.physicalSource], 1);
|
||||
assert.equal(player(f.splitMovement.after, f.actor).forces[f.markerSource], 1);
|
||||
assert.equal(player(f.afterFirstPreparation, f.actor).spice, 1);
|
||||
assert.ok(f.charity);
|
||||
assert.equal(f.charity.before.turn, 2); assert.equal(f.charity.before.phase, 2);
|
||||
assert.equal(f.charity.step.actor, f.actor); assert.equal(f.charity.step.action.type, 'charity');
|
||||
assert.equal(player(f.charity.before, f.actor).spice, 1);
|
||||
assert.equal(player(f.charity.after, f.actor).spice, 2);
|
||||
assert.deepEqual(player(f.charity.before, f.actor).forces, player(f.charity.after, f.actor).forces);
|
||||
assert.equal(player(f.beforeShipment, f.actor).spice, 2, 'The played second-turn charity funds the marker shipment');
|
||||
} else { assert.equal(f.splitMovement, null); assert.equal(f.charity, null); }
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
}
|
||||
function moved(f: PlanetologistNoFieldGatherFixture, 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.physicalSource] ?? 0, 0); assert.equal(after.forces[f.markerSource] ?? 0, 0);
|
||||
assert.equal(after.forces[f.targetLocation], f.amount);
|
||||
assert.equal(presenceAt(after, f.target.territory), f.amount + 1,
|
||||
'Only the original selected counters and one concealed marker gather; no denomination materializes');
|
||||
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 });
|
||||
const chronicle = game.log.slice(f.game.log.length).find(entry => entry.text.includes('moved a concealed No-Field'));
|
||||
assert.ok(chronicle);
|
||||
assert.ok(chronicle.text.includes(`from ${territory(f.sourceLocation.territory).name} and ${territory(f.physicalSourceLocation.territory).name} to ${territory(f.target.territory).name}`),
|
||||
'The real chronicle identifies both normalized origins in their original marker-first order');
|
||||
assert.ok(chronicle.text.endsWith('Its value remains concealed.'));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const value of [0, 3, 5] as const) for (const withMarkerCompanion of [false, true]) {
|
||||
void test(`public ${advanced ? 'Advanced' : 'Basic'} concealed${value} ${withMarkerCompanion ? 'with an earned marker companion' : 'and a remote physical counter'} gathers two original origins once`, () => {
|
||||
const f = fixture({ advanced, value, withMarkerCompanion }); source(f);
|
||||
rejected(f.game, f.actor, f.tooFarAction);
|
||||
rejected(f.game, f.actor, { ...f.action, planetologist: 'range' });
|
||||
const ordinary: Action = { ...f.action }; delete ordinary.planetologist;
|
||||
rejected(f.game, f.actor, ordinary);
|
||||
const before = structuredClone(f.game);
|
||||
let game = move(f);
|
||||
assert.deepEqual(f.game, before); moved(f, game);
|
||||
const wallets = game.players.map(seat => seat.spice);
|
||||
assert.deepEqual(wallets, f.game.players.map(seat => seat.spice));
|
||||
rejected(game, f.actor, f.action);
|
||||
const relocated = structuredClone(player(game, f.actor).noField), event = player(game, f.actor).noFieldEvent;
|
||||
const continuation: { actor: string; action: Action }[] = [];
|
||||
game = finish(f, game, continuation);
|
||||
assert.equal(game.turn, 2); 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, event);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native marker gather rejects stale, foreign and malformed origin selections before moving either source', () => {
|
||||
const f = fixture({ withMarkerCompanion: true }); 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: {} });
|
||||
rejected(f.game, f.actor, { ...f.action, forces: { [f.markerSource]: 1 } });
|
||||
const physicalOnly: Action = { ...f.action, forces: { [f.physicalSource]: 1 } };
|
||||
delete physicalOnly.noField; delete physicalOnly.event;
|
||||
rejected(f.game, f.actor, physicalOnly);
|
||||
rejected(f.game, f.actor, { ...f.action, origins: [f.sourceLocation.territory, f.physicalSourceLocation.territory] });
|
||||
rejected(f.game, f.actor, { ...f.action, forces: { ...f.forces, 'arrakeen:10': 1 } });
|
||||
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 and third-origin supplements cannot bypass the original gather bounds', () => {
|
||||
const f = fixture(); source(f);
|
||||
// These labelled controlled inputs do not claim played death/capture or a
|
||||
// third paid shipment. All twelve native positive sources above are unstaged.
|
||||
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);
|
||||
}
|
||||
const three = structuredClone(f.game), owner = player(three, f.actor);
|
||||
owner.reserves--; owner.forces[f.tooFarLocation] = 1;
|
||||
assert.equal(owner.reserves + owner.tanks + Object.values(owner.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
rejected(three, f.actor, { ...f.action, forces: { ...f.forces, [f.tooFarLocation]: 1 } });
|
||||
assert.deepEqual(physical(f.game), f.originalPhysical);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles offer a legal original marker plus remote-counter gather', () => {
|
||||
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 === 'gather' && action.noField === f.token.id);
|
||||
assert.ok(candidates.length, `${difficulty} offers an actual concealed${value} two-origin gather`);
|
||||
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 };
|
||||
const destinationLocation = location(destination.territory, destination.sector);
|
||||
assert.deepEqual(action.forces, { [f.physicalSource]: 1 });
|
||||
assert.equal(Object.hasOwn(action, 'origins'), false); assert.equal(action.event, f.originalEvent);
|
||||
const me = view.players.find(seat => seat.id === f.actor)!;
|
||||
for (const from of [f.markerSource, f.physicalSource])
|
||||
assert.equal(botGroundMoveAllowed(view, me, from, destinationLocation, 0, 'gather'), true);
|
||||
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.equal(after.forces[f.physicalSource] ?? 0, 0);
|
||||
assert.equal(after.forces[destinationLocation], 1);
|
||||
assert.equal(after.moved, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.notEqual(splitLocation(f.physicalSource).territory, f.sourceLocation.territory);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user