Files
Dune/game/fremen-ambassador.ts
T

459 lines
15 KiB
TypeScript

import type { Action, Game, Player } from './engine';
import {
gameTerritories,
location,
MOBILE_STRONGHOLD,
splitLocation,
TERRITORIES,
validGameLocation,
} from './board';
import { arrivalAsAdvisor } from './advisors';
import { presenceAt } from './force-presence';
import { territoryEntryBlock } from './occupancy';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { homeworldNoFieldMovementBlock } from './homeworld-mobility';
import { sandmasterDestinationCollection } from './sandmaster-movement';
import { isBoardNoFieldLocation } from './richese-no-field';
export class FremenAmbassadorMoveError extends Error {
constructor(message: string) {
super(message);
this.name = 'FremenAmbassadorMoveError';
}
}
export type FremenAmbassadorMove = {
player: string;
group: [string, number][];
eliteGroup: Record<string, number>;
elite: number;
origin: string;
total: number;
to: string;
sector: number;
advisors: boolean;
wantsFighters: boolean;
lockedTurn?: number;
noField?: { tokenId: string; event: string; from: string };
ambassadorSandmaster?: { leader: string; key: string; before: number };
};
export type FremenAmbassadorMovement = {
sources: {
territory: string;
sectors: { key: string; forces: number; elites: number }[];
marker: { tokenId: string; event: string; sector: number } | null;
destinations: {
territory: string;
sector: number;
advisors: boolean;
/** Whether an arriving advisor group can request a flip to fighters. */
canFight: boolean;
}[];
}[];
};
function requireMove(value: unknown, message: string): asserts value {
if (!value) throw new FremenAmbassadorMoveError(message);
}
function count(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function clear(g: Game, sector: number) {
return sector === 0 || sector !== g.storm;
}
function usableLocation(g: Game, key: string) {
const at = splitLocation(key);
return (
location(at.territory, at.sector) === key &&
validGameLocation(g, at.territory, at.sector) &&
(at.territory !== MOBILE_STRONGHOLD || !!g.mobileStronghold?.location)
);
}
function printedLocation(key: string) {
const at = splitLocation(key);
return TERRITORIES.some(
(territory) =>
territory.id === at.territory && territory.sectors.includes(at.sector),
);
}
function playerFor(g: Game, id: string) {
requireMove(
Array.isArray(g.players) &&
g.players.length > 0 &&
new Set(g.players.map((p) => p.id)).size === g.players.length,
'Relocation requires unique seated players.',
);
requireMove(
count(g.turn) &&
g.turn > 0 &&
count(g.phase) &&
g.phase <= 8 &&
count(g.storm) &&
g.storm <= 18,
'The relocation turn or storm is invalid.',
);
requireMove(
!g.mobileStronghold?.location ||
(usableLocation(g, g.mobileStronghold.location) &&
printedLocation(g.mobileStronghold.location)),
'The mobile stronghold must point to a board territory.',
);
for (const p of g.players) {
requireMove(
typeof p.id === 'string' &&
p.id.length > 0 &&
(!p.ally ||
g.players.some((other) => other.id === p.ally && other.id !== p.id)),
'The relocation player or alliance is invalid.',
);
requireMove(
record(p.forces) && (!p.elites || record(p.elites.forces)),
'Relocation requires a valid physical force inventory.',
);
for (const [key, n] of Object.entries(p.forces))
requireMove(
usableLocation(g, key) && count(n),
'Invalid board force location or quantity.',
);
for (const [key, n] of Object.entries(p.elites?.forces ?? {}))
requireMove(
usableLocation(g, key) && count(n) && n <= (p.forces[key] ?? 0),
'Elite forces must be part of the physical force inventory.',
);
const marker = p.noField?.deployed;
requireMove(
!marker || !isBoardNoFieldLocation(marker.location) ||
usableLocation(
g,
location(marker.location.territory, marker.location.sector),
),
'The concealed marker has an invalid board location.',
);
for (const [t, stance] of Object.entries(p.advisors ?? {}))
requireMove(
gameTerritories(g).some((candidate) => candidate.id === t) &&
record(stance) &&
(stance.lockedTurn === undefined || count(stance.lockedTurn)),
'The advisor stance or lock is invalid.',
);
}
const p = g.players.find((candidate) => candidate.id === id);
requireMove(p, 'The beneficiary is not seated.');
return p;
}
function ownMarker(p: Player) {
const marker = p.noField?.deployed;
if (!marker || !isBoardNoFieldLocation(marker.location)) return null;
requireMove(
p.faction === 'richese' &&
marker.controller === p.id &&
typeof marker.tokenId === 'string' &&
marker.tokenId.length > 0 &&
typeof p.noFieldEvent === 'string' &&
p.noFieldEvent.length > 0,
'The concealed marker custody is not current.',
);
return {
tokenId: marker.tokenId,
event: p.noFieldEvent,
from: location(marker.location.territory, marker.location.sector),
};
}
/** Pure board relocation only. The caller binds the Ambassador event, actor and
* saved continuation. This never inspects cards, promises or marker denomination,
* moves units, consumes movement, samples a draw, or creates an event. */
export function quoteFremenAmbassadorMove(
g: Game,
beneficiaryId: string,
action: Action,
): FremenAmbassadorMove {
const p = playerFor(g, beneficiaryId);
requireMove(
record(action.forces),
'Choose a physical force group from one territory.',
);
requireMove(
action.eliteForces === undefined || record(action.eliteForces),
'Choose an elite allocation for the force group.',
);
requireMove(
action.fighters === undefined || typeof action.fighters === 'boolean',
'Choose whether arriving advisors request fighters.',
);
const group: [string, number][] = [];
const eliteGroup: Record<string, number> = {};
let origin: string | undefined;
let noField: FremenAmbassadorMove['noField'];
if (action.noField !== undefined) {
const blocked = homeworldNoFieldMovementBlock(g, p.id);
requireMove(!blocked, blocked ?? 'This No-Field cannot move.');
const marker = ownMarker(p);
requireMove(
record(action.noField) &&
marker &&
action.noField.tokenId === marker.tokenId &&
action.noField.event === marker.event,
'The selected concealed marker is no longer current.',
);
noField = marker;
origin = splitLocation(marker.from).territory;
requireMove(
clear(g, splitLocation(marker.from).sector),
'The concealed marker cannot leave a sector in storm.',
);
}
for (const [key, value] of Object.entries(action.forces)) {
requireMove(
usableLocation(g, key) && count(value) && value <= (p.forces[key] ?? 0),
'Select available physical forces from valid sectors.',
);
if (!value) continue;
const at = splitLocation(key);
requireMove(
!origin || origin === at.territory,
'A force group must come from one territory.',
);
requireMove(clear(g, at.sector), 'Forces cannot leave a sector in storm.');
origin = at.territory;
const available = p.forces[key],
eliteAvailable = p.elites?.forces[key] ?? 0;
const minimum = Math.max(0, value - (available - eliteAvailable));
const selectedElite =
(action.eliteForces as Record<string, unknown> | undefined)?.[key] ??
minimum;
requireMove(
count(selectedElite) &&
selectedElite >= minimum &&
selectedElite <= Math.min(value, eliteAvailable),
'Choose an available elite allocation within the selected physical forces.',
);
group.push([key, value]);
eliteGroup[key] = selectedElite;
}
for (const [key, value] of Object.entries(action.eliteForces ?? {}))
requireMove(
count(value) && (value === 0 || group.some(([source]) => source === key)),
'Elite allocations must belong to selected physical forces.',
);
requireMove(
origin && (group.length > 0 || noField),
'Choose at least one physical force or the concealed marker.',
);
const to = action.territory,
sector = action.sector;
requireMove(
typeof to === 'string' &&
count(sector) &&
usableLocation(g, location(to, sector)),
'Choose a sector belonging to an available destination territory.',
);
requireMove(clear(g, sector), 'The destination sector is in storm.');
const target = location(to, sector);
requireMove(
[...group.map(([key]) => key), ...(noField ? [noField.from] : [])].every(
(key) => key !== target,
),
'Select forces or a marker that actually change location.',
);
requireMove(
to !== MOBILE_STRONGHOLD ||
p.faction === 'ixians' ||
origin === splitLocation(g.mobileStronghold?.location ?? '').territory,
'Enter the mobile stronghold from the territory it points to.',
);
const advisors = arrivalAsAdvisor(g, p, to, origin);
const lockedTurn = p.advisors?.[origin]?.lockedTurn;
requireMove(
advisors ||
lockedTurn !== g.turn ||
!g.players.some((other) => other.id !== p.id && presenceAt(other, to)),
'New advisors cannot become fighters this turn.',
);
const wantsFighters = advisors && action.fighters === true;
requireMove(
!wantsFighters ||
(lockedTurn !== g.turn &&
!presenceAt(p, to) &&
(!(
p.ally &&
presenceAt(
g.players.find((other) => other.id === p.ally)!,
to,
)
) ||
ecazOccupancyRelation(g.players, p.id, p.ally!, {
kind: 'territory',
id: to,
}) === 'ecazAlliance')),
'These advisors cannot flip to fighters on arrival.',
);
try {
const blocked = territoryEntryBlock(
g.players,
p.id,
to,
advisors && !wantsFighters,
);
requireMove(!blocked, blocked ?? 'This destination cannot be entered.');
} catch (error) {
if (error instanceof FremenAmbassadorMoveError) throw error;
throw new FremenAmbassadorMoveError(
error instanceof Error ? error.message : 'Invalid territory occupancy.',
);
}
const choam = g.players.find((other) => other.faction === 'choam');
requireMove(
!(
g.phase === 5 &&
origin !== to &&
choam &&
presenceAt(choam, to) > 0 &&
g.choamBaliset?.some(
(entry) =>
entry.turn === g.turn &&
entry.player === p.id &&
entry.territory === to,
)
),
'Baliset prevents movement into that CHOAM territory.',
);
const physical = group.reduce((sum, [, n]) => sum + n, 0);
const elite = Object.values(eliteGroup).reduce((sum, n) => sum + n, 0);
const total = physical + (noField ? 1 : 0);
requireMove(
count(total) &&
count(elite) &&
count((p.forces[target] ?? 0) + physical) &&
count((p.elites?.forces[target] ?? 0) + elite),
'The resulting force quantity is invalid.',
);
requireMove(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean',
'Choose whether to collect spice with Sandmaster.');
requireMove(action.sandmasterPile === undefined ||
(action.sandmasterCollect === true && typeof action.sandmasterPile === 'string'),
'Choose a Sandmaster spice pile only when collecting.');
let ambassadorSandmaster: FremenAmbassadorMove['ambassadorSandmaster'];
if (action.sandmasterCollect) {
requireMove(physical > 0 && !noField,
'Sandmaster collection with concealed Ambassador relocation is still being integrated.');
const quote = sandmasterDestinationCollection(
g, p.id, origin, to, sector, action.sandmasterPile,
);
requireMove(quote && !quote.blocked && quote.key,
quote?.blocked ?? 'Sandmaster collection needs the living native trainer.');
requireMove(quote.piles.length <= 1 || typeof action.sandmasterPile === 'string',
'Choose the destination spice pile for Sandmaster collection.');
ambassadorSandmaster = { leader: quote.leader, key: quote.key, before: quote.before };
}
return {
player: p.id,
group,
eliteGroup,
elite,
origin,
total,
to,
sector,
advisors,
wantsFighters,
...(lockedTurn === undefined ? {} : { lockedTurn }),
...(noField ? { noField } : {}),
...(ambassadorSandmaster ? { ambassadorSandmaster } : {}),
};
}
/** Finite existential source/destination domain; one minimum physical force or
* marker witnesses each destination. No enumeration of force subsets is needed. */
export function fremenAmbassadorMovement(
g: Game,
beneficiaryId: string,
): FremenAmbassadorMovement {
const p = playerFor(g, beneficiaryId);
const marker = homeworldNoFieldMovementBlock(g, p.id) ? null : ownMarker(p);
const sources: FremenAmbassadorMovement['sources'] = [];
for (const t of gameTerritories(g)) {
const sectors = Object.entries(p.forces)
.filter(
([key, n]) =>
n > 0 &&
splitLocation(key).territory === t.id &&
clear(g, splitLocation(key).sector),
)
.map(([key, forces]) => ({
key,
forces,
elites: p.elites?.forces[key] ?? 0,
}));
const sourceMarker =
marker &&
splitLocation(marker.from).territory === t.id &&
clear(g, splitLocation(marker.from).sector)
? {
tokenId: marker.tokenId,
event: marker.event,
sector: splitLocation(marker.from).sector,
}
: null;
if (!sectors.length && !sourceMarker) continue;
const destinations: FremenAmbassadorMovement['sources'][number]['destinations'] =
[];
for (const to of gameTerritories(g))
for (const sector of to.sectors) {
if (!clear(g, sector)) continue;
const target = location(to.id, sector);
const source = sectors.find((candidate) => candidate.key !== target);
const selectedMarker =
!source && sourceMarker && marker!.from !== target
? sourceMarker
: null;
if (!source && !selectedMarker) continue;
const action: Action = {
type: 'decision',
territory: to.id,
sector,
forces: source ? { [source.key]: 1 } : {},
...(selectedMarker
? {
noField: {
tokenId: selectedMarker.tokenId,
event: selectedMarker.event,
},
}
: {}),
};
try {
const quote = quoteFremenAmbassadorMove(g, p.id, action);
let canFight = false;
if (quote.advisors) {
try {
canFight = quoteFremenAmbassadorMove(g, p.id, {
...action,
fighters: true,
}).wantsFighters;
} catch (error) {
if (!(error instanceof FremenAmbassadorMoveError)) throw error;
}
}
destinations.push({
territory: to.id,
sector,
advisors: quote.advisors,
canFight,
});
} catch (error) {
if (!(error instanceof FremenAmbassadorMoveError)) throw error;
}
}
if (destinations.length)
sources.push({
territory: t.id,
sectors,
marker: sourceMarker,
destinations,
});
}
return { sources };
}