155 lines
12 KiB
TypeScript
155 lines
12 KiB
TypeScript
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);
|
|
}
|