Share dynamic stronghold legality across placement, cancellation, bots and controls. Keep the HMS sheltered, preserve original fees and entry effects, and display its public Ambassador attached to the stronghold interior. Cover genuine Basic/Advanced placement, paid movement entry, card continuations, prevention and native relocation. No Terror or direct-shipment permission is added.
200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
import { terrorLocationAllowed, type TerrorLocationContext } from './terror-location';
|
|
import type { Game, ResponseWindow } from './engine';
|
|
import { ambassadorPlacementDestination } from './ecaz-ambassador-placement';
|
|
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'
|
|
| 'storm'
|
|
| 'mobileStronghold'
|
|
| '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 &&
|
|
ambassadorPlacementDestination(g, pending.territory).allowed &&
|
|
ambassadorPlacementDestination(g, pending.territory).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' };
|
|
}
|