149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
import type { Game } from './engine';
|
|
import type { FactionId } from './catalog';
|
|
import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
|
|
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
|
|
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
|
|
|
export type EcazDukeContext = Omit<Partial<FactionModuleProfile>, 'players'> & {
|
|
status: Game['status'];
|
|
turn: number;
|
|
advanced: boolean;
|
|
players: readonly { id: string; faction: FactionId; ally?: string | null }[];
|
|
dukeVidal?: DukeState;
|
|
};
|
|
type Failure = 'context' | 'custody' | 'table' | 'alreadyControlled' | 'sameHolderTable';
|
|
export class EcazDukeAcquisitionError extends Error {
|
|
constructor(
|
|
message: string,
|
|
readonly reason: Failure = 'custody',
|
|
) {
|
|
super(message);
|
|
this.name = 'EcazDukeAcquisitionError';
|
|
}
|
|
}
|
|
function requireDuke(
|
|
condition: unknown,
|
|
message: string,
|
|
reason: Failure = 'custody',
|
|
): asserts condition {
|
|
if (!condition) throw new EcazDukeAcquisitionError(message, reason);
|
|
}
|
|
const whole = (n: unknown): n is number =>
|
|
Number.isSafeInteger(n) && (n as number) >= 0;
|
|
const tableBlock =
|
|
'Duke Vidal acquisition with Advanced Harkonnen is still being implemented.';
|
|
const sameHolderTableBlock =
|
|
'Same-holder Duke acquisition with Advanced Harkonnen is not available in this preview.';
|
|
const alreadyBlock =
|
|
'Ecaz already controls Duke Vidal through its Ambassador.';
|
|
const allyAlreadyBlock = 'The chosen beneficiary already controls Duke Vidal through the Ambassador.';
|
|
const unavailable = 'Duke Vidal is unavailable for this acquisition.';
|
|
|
|
/** Ordinary Ambassador acquisition for Ecaz or its current reciprocal ally.
|
|
* Both use source Ecaz's until-battle tenure, not the new-alliance turn loan.
|
|
* The caller owns the actual entry event, physical Ambassador, occupied-Ecaz
|
|
* priority and explicit choice. No disc or new battle permission is created. */
|
|
export function quoteEcazDukeAcquisition(
|
|
g: EcazDukeContext,
|
|
ownerId: string,
|
|
recipientId: string = ownerId,
|
|
): DukeState {
|
|
requireDuke(
|
|
g.status === 'playing' &&
|
|
whole(g.turn) &&
|
|
g.turn > 0 &&
|
|
typeof g.advanced === 'boolean' &&
|
|
Array.isArray(g.players) &&
|
|
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
|
g.players.filter((p) => p.faction === 'ecaz').length === 1 &&
|
|
g.players.filter((p) => p.id === ownerId && p.faction === 'ecaz')
|
|
.length === 1,
|
|
'This acquisition needs the current seated Ecaz owner.',
|
|
'context',
|
|
);
|
|
const owner = g.players.find((p) => p.id === ownerId)!;
|
|
const recipient = g.players.find((p) => p.id === recipientId);
|
|
requireDuke(
|
|
recipient && (recipientId === ownerId ||
|
|
(owner.ally === recipientId && recipient.ally === ownerId)),
|
|
'Choose Ecaz or its current reciprocal ally for this acquisition.',
|
|
'context',
|
|
);
|
|
requireDuke(
|
|
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
|
|
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
|
|
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
|
|
tableBlock,
|
|
'table',
|
|
);
|
|
const duke = g.dukeVidal;
|
|
requireDuke(
|
|
duke &&
|
|
typeof duke === 'object' &&
|
|
!Array.isArray(duke) &&
|
|
duke.leader &&
|
|
typeof duke.leader === 'object' &&
|
|
!Array.isArray(duke.leader) &&
|
|
duke.leader.id === DUKE_VIDAL_ID &&
|
|
duke.leader.faction === 'ecaz' &&
|
|
duke.leader.strength === 6 &&
|
|
typeof duke.leader.name === 'string' &&
|
|
duke.leader.name.length > 0 &&
|
|
typeof duke.leader.dead === 'boolean' &&
|
|
whole(duke.leader.deaths),
|
|
'The shared Duke Vidal disc has invalid physical identity or history.',
|
|
);
|
|
// Harkonnen may privately capture Duke without changing his public prior
|
|
// controller. Keep same-holder availability independent of that hidden
|
|
// custody until a public-safe capture continuation is implemented.
|
|
requireDuke(duke.controller !== recipientId || !g.advanced ||
|
|
!g.players.some(player => player.faction === 'harkonnen'),
|
|
sameHolderTableBlock, 'sameHolderTable');
|
|
// A direct Ambassador grant is already in force for this recipient. Other
|
|
// sources may be converted to this until-battle tenure without moving or
|
|
// recreating the disc. Check public tenure before private custody details.
|
|
requireDuke(duke.controller !== recipientId || duke.source !== 'ecaz',
|
|
recipientId === ownerId ? alreadyBlock : allyAlreadyBlock, 'alreadyControlled');
|
|
requireDuke(
|
|
!duke.leader.concealed,
|
|
'Duke Vidal cannot be acquired from unresolved concealed custody.',
|
|
);
|
|
requireDuke(
|
|
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', 'ecazNexus', 'moritani', 'ally'].includes(duke.source!),
|
|
'The shared Duke Vidal disc has invalid current custody.',
|
|
);
|
|
requireDuke(
|
|
!duke.leader.dead && !duke.leader.capturedBy && !duke.leader.gholaBy,
|
|
'Duke Vidal cannot be acquired from dead, captured or ghola custody.',
|
|
);
|
|
// acquireDuke clones every existing leader field, including usedAt and death
|
|
// history; retaining those fields does not permit a second battle use.
|
|
return acquireDuke(duke, recipientId, g.turn, 'ecaz');
|
|
}
|
|
/** An owner-offer descriptor may be publicly compared. Exceptional custody
|
|
* never produces a distinct reason that discloses a hidden captured/ghola ID. */
|
|
export function ecazDukeAcquisitionBlock(
|
|
g: EcazDukeContext,
|
|
ownerId: string,
|
|
recipientId: string = ownerId,
|
|
): string | null {
|
|
try {
|
|
quoteEcazDukeAcquisition(g, ownerId, recipientId);
|
|
return null;
|
|
} catch (error) {
|
|
if (error instanceof EcazDukeAcquisitionError) {
|
|
if (error.reason === 'table') return tableBlock;
|
|
if (error.reason === 'sameHolderTable') return sameHolderTableBlock;
|
|
if (error.reason === 'alreadyControlled')
|
|
return recipientId === ownerId ? alreadyBlock : allyAlreadyBlock;
|
|
}
|
|
return unavailable;
|
|
}
|
|
}
|