199 lines
6.8 KiB
TypeScript
199 lines
6.8 KiB
TypeScript
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
|
|
import type { Game, ResponseWindow } from './engine';
|
|
import { TERRITORIES } from './board';
|
|
import {
|
|
blockAmbassadorPlacement,
|
|
type AmbassadorState,
|
|
} from './ecaz-ambassadors';
|
|
import {
|
|
TERROR_KINDS,
|
|
TERROR_STRONGHOLDS,
|
|
type TerrorState,
|
|
} from './moritani-terror';
|
|
import { DUKE_VIDAL_ID } from './duke-vidal';
|
|
|
|
export class PlacementCancellationError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'PlacementCancellationError';
|
|
}
|
|
}
|
|
function requirePlacement(
|
|
condition: unknown,
|
|
message: string,
|
|
): asserts condition {
|
|
if (!condition) throw new PlacementCancellationError(message);
|
|
}
|
|
const whole = (n: unknown): n is number =>
|
|
Number.isSafeInteger(n) && (n as number) >= 0;
|
|
export type PlacementCancellationContext = Pick<
|
|
Game,
|
|
| 'status'
|
|
| 'phase'
|
|
| 'turn'
|
|
| 'players'
|
|
| 'pendingEcazPlacement'
|
|
| 'ecazPlacementTurn'
|
|
| 'ecazAmbassadors'
|
|
| 'pendingMoritaniPlacement'
|
|
| 'moritaniTerror'
|
|
| 'dukeVidal'
|
|
| 'dukeAcquisitionTurn'
|
|
| 'movementRemaining'
|
|
> & TerrorLocationContext;
|
|
export type PlacementCancellationQuote =
|
|
| {
|
|
kind: 'ecazPlacement';
|
|
ambassadors: AmbassadorState;
|
|
completedTurn: number;
|
|
suffix: 'completePhase';
|
|
}
|
|
| {
|
|
kind: 'moritaniPlacement';
|
|
terror: TerrorState;
|
|
suffix: 'finishMoritaniPlacement';
|
|
}
|
|
| { kind: 'moritaniDuke'; suffix: 'nextPhase' };
|
|
/** Source and denied receipt only. The engine composes the explicit suffix with
|
|
* movement, victory or phase-boundary quotes; none runs in this function. */
|
|
export function quotePlacementCancellation(
|
|
g: PlacementCancellationContext,
|
|
response: Pick<ResponseWindow, 'kind' | 'owner'>,
|
|
): PlacementCancellationQuote | null {
|
|
if (
|
|
!['ecazPlacement', 'moritaniPlacement', 'moritaniDuke'].includes(
|
|
response.kind,
|
|
)
|
|
)
|
|
return null;
|
|
const owner = g.players.find((p) => p.id === response.owner);
|
|
requirePlacement(
|
|
g.status === 'playing' &&
|
|
whole(g.turn) &&
|
|
g.turn > 0 &&
|
|
owner &&
|
|
g.players.filter((p) => p.id === response.owner).length === 1 &&
|
|
owner.faction ===
|
|
(response.kind === 'ecazPlacement' ? 'ecaz' : 'moritani'),
|
|
'This placement cancellation has no current faction owner.',
|
|
);
|
|
if (response.kind === 'ecazPlacement') {
|
|
const pending = g.pendingEcazPlacement,
|
|
state = g.ecazAmbassadors;
|
|
requirePlacement(
|
|
g.phase === 4 &&
|
|
pending &&
|
|
pending.turn === g.turn &&
|
|
g.ecazPlacementTurn !== g.turn &&
|
|
state &&
|
|
Array.isArray(state.tokens) &&
|
|
Array.isArray(state.cohort) &&
|
|
state.tokens.filter((t) => t.id === pending.token).length === 1 &&
|
|
TERRITORIES.some(
|
|
(t) => t.id === pending.territory && t.type === 'stronghold',
|
|
) &&
|
|
whole(pending.cost) &&
|
|
pending.cost > 0,
|
|
'No current physical Ambassador placement is awaiting cancellation.',
|
|
);
|
|
let ambassadors: AmbassadorState;
|
|
try {
|
|
ambassadors = blockAmbassadorPlacement(state, g.turn);
|
|
} catch (error) {
|
|
throw new PlacementCancellationError(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Invalid Ambassador inventory.',
|
|
);
|
|
}
|
|
requirePlacement(
|
|
state.placement?.turn !== g.turn || !state.placement.blocked,
|
|
'The current Ambassador placement opportunity is already blocked.',
|
|
);
|
|
requirePlacement(
|
|
pending.cost ===
|
|
(state.placement?.turn === g.turn ? state.placement.count : 0) + 1,
|
|
'The declared Ambassador placement price has no current placement receipt.',
|
|
);
|
|
return {
|
|
kind: 'ecazPlacement',
|
|
ambassadors,
|
|
completedTurn: g.turn,
|
|
suffix: 'completePhase',
|
|
};
|
|
}
|
|
if (response.kind === 'moritaniPlacement') {
|
|
const pending = g.pendingMoritaniPlacement,
|
|
state = g.moritaniTerror;
|
|
requirePlacement(
|
|
g.phase === 8 &&
|
|
pending &&
|
|
pending.turn === g.turn &&
|
|
state &&
|
|
Array.isArray(state.tokens) &&
|
|
(state.placementTurn === undefined ||
|
|
(whole(state.placementTurn) && state.placementTurn < g.turn)) &&
|
|
(state.supplyEpoch === undefined || whole(state.supplyEpoch)) &&
|
|
state.tokens.length === 6 &&
|
|
new Set(state.tokens.map((t) => t.id)).size === 6 &&
|
|
new Set(state.tokens.map((t) => t.kind)).size === 6 &&
|
|
state.tokens.every(
|
|
(t) =>
|
|
typeof t.id === 'string' &&
|
|
!!t.id &&
|
|
TERROR_KINDS.includes(t.kind) &&
|
|
['available', 'placed', 'removed', 'extortion'].includes(
|
|
t.status,
|
|
) &&
|
|
(t.status === 'placed'
|
|
? typeof t.location === 'string' &&
|
|
terrorLocationAllowed(g,t.id,t.location)
|
|
: t.location === null) &&
|
|
(t.status !== 'extortion' || t.kind === 'extortion'),
|
|
) &&
|
|
state.tokens.filter((t) => t.id === pending.token).length === 1 &&
|
|
(pending.nexusEvent ? terrorLocationAllowed(g,pending.token,pending.territory,pending.nexusEvent,true) : TERROR_STRONGHOLDS.includes(pending.territory)),
|
|
'No current physical Terror placement is awaiting cancellation.',
|
|
);
|
|
// Grumman may have created legal stacks earlier. Denial validates physical
|
|
// custody and the pending source receipt, not whether that placement could
|
|
// still succeed: no token moves, even if its source or target has changed.
|
|
return {
|
|
kind: 'moritaniPlacement',
|
|
terror: {
|
|
...state,
|
|
tokens: state.tokens.map((t) => ({ ...t })),
|
|
placementTurn: g.turn,
|
|
},
|
|
suffix: 'finishMoritaniPlacement',
|
|
};
|
|
}
|
|
const duke = g.dukeVidal;
|
|
requirePlacement(
|
|
g.phase === 5 &&
|
|
g.dukeAcquisitionTurn === g.turn &&
|
|
Array.isArray(g.movementRemaining) &&
|
|
g.movementRemaining.length === 0 &&
|
|
duke &&
|
|
duke.leader &&
|
|
duke.leader.id === DUKE_VIDAL_ID &&
|
|
duke.leader.faction === 'ecaz' &&
|
|
duke.leader.strength === 6 &&
|
|
typeof duke.leader.dead === 'boolean' &&
|
|
whole(duke.leader.deaths) &&
|
|
g.players.every((p) => !p.leaders.some((l) => l.id === DUKE_VIDAL_ID)) &&
|
|
(duke.controller === null
|
|
? duke.acquiredTurn === null && duke.source === null
|
|
: g.players.some((p) => p.id === duke.controller) &&
|
|
whole(duke.acquiredTurn) &&
|
|
duke.acquiredTurn > 0 &&
|
|
duke.acquiredTurn <= g.turn &&
|
|
['ecaz', 'moritani', 'ally'].includes(duke.source!)) &&
|
|
[duke.leader.capturedBy, duke.leader.gholaBy].every(
|
|
(id) => id === undefined || g.players.some((p) => p.id === id),
|
|
),
|
|
'This Duke Vidal denial has no current movement completion or shared-disc custody.',
|
|
);
|
|
return { kind: 'moritaniDuke', suffix: 'nextPhase' };
|
|
}
|