153 lines
7.9 KiB
TypeScript
153 lines
7.9 KiB
TypeScript
import {
|
||
HomeworldCustodyError, homeworldForceGroups, type HomeworldCustody,
|
||
} from './homeworld-custody';
|
||
import { quoteHomeworldShipment, type HomeworldShipmentContext } from './homeworld-shipment';
|
||
import type { HomeworldForces } from './homeworld-custody';
|
||
import { deployRicheseNoField, isHomeworldNoFieldLocation, moveRicheseNoField, type RicheseNoField } from './richese-no-field';
|
||
|
||
export type RicheseHomeworldMarkerShipmentIntent = {
|
||
player: string;
|
||
destination: string;
|
||
tokenId: string;
|
||
/** Optional physical companions withdrawn from native Richese reserves. */
|
||
forces?: HomeworldForces;
|
||
};
|
||
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
|
||
source: string;
|
||
forces?: HomeworldForces;
|
||
};
|
||
|
||
const record = (value: unknown): value is Record<string, unknown> =>
|
||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||
function requireMarker(condition: unknown, message: string): asserts condition {
|
||
if (!condition) throw new HomeworldCustodyError(message);
|
||
}
|
||
|
||
/** A fresh concealed marker counts as one for the whole shipment invoice. Any
|
||
* physical companions are independently withdrawn from native Richese reserves;
|
||
* the marker's denomination never enters the price or physical custody. */
|
||
export function quoteRicheseHomeworldMarkerShipment(
|
||
context: HomeworldShipmentContext,
|
||
custody: HomeworldCustody,
|
||
intent: RicheseHomeworldMarkerShipmentIntent,
|
||
noField: RicheseNoField,
|
||
) {
|
||
// This validates all physical reserve and foreign garrison custody first.
|
||
const worlds = homeworldForceGroups(context, custody);
|
||
requireMarker(record(intent) &&
|
||
(Object.keys(intent).sort().join(',') === 'destination,player,tokenId' ||
|
||
Object.keys(intent).sort().join(',') === 'destination,forces,player,tokenId') &&
|
||
typeof intent.player === 'string' && typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
|
||
'Choose a Richese player, one active Homeworld and one physical No-Field.');
|
||
if ('forces' in intent) {
|
||
requireMarker(record(intent.forces) && Object.keys(intent.forces).sort().join(',') === 'elite,normal' &&
|
||
Number.isSafeInteger(intent.forces.normal) && Number.isSafeInteger(intent.forces.elite) &&
|
||
intent.forces.normal >= 0 && intent.forces.elite >= 0,
|
||
'Choose a valid physical normal and special counter group.');
|
||
}
|
||
requireMarker(context.players.every(seat => seat.ally === null ||
|
||
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
|
||
'Homeworld shipment needs valid seated alliance identities.');
|
||
const player = context.players.find(seat => seat.id === intent.player);
|
||
const source = worlds.find(world => world.id === 'homeworld:richese');
|
||
const destination = worlds.find(world => world.id === intent.destination);
|
||
requireMarker(player?.faction === 'richese' && source?.native === player.id,
|
||
'Only native Richese can ship its concealed No-Field.');
|
||
requireMarker(destination && destination.native !== player.id,
|
||
'Choose another seated faction’s Homeworld.');
|
||
const native = context.players.find(seat => seat.id === destination.native)!;
|
||
requireMarker(!(player.ally === native.id && native.ally === player.id),
|
||
'You cannot ship to your ally’s Homeworld.');
|
||
|
||
let deployed: RicheseNoField;
|
||
try {
|
||
deployed = deployRicheseNoField(noField, {
|
||
tokenId: intent.tokenId,
|
||
controller: player.id,
|
||
location: { homeworld: destination.id },
|
||
});
|
||
} catch (error) {
|
||
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field shipment.');
|
||
}
|
||
if (intent.forces && intent.forces.normal + intent.forces.elite > 0) {
|
||
const physical = quoteHomeworldShipment(context, custody, {
|
||
player: player.id, destination: destination.id, sources: { [source.id]: intent.forces },
|
||
});
|
||
const amount = 1 + physical.amount;
|
||
return { player: player.id, tokenId: intent.tokenId, source: source.id,
|
||
destination: destination.id, amount, elite: physical.elite,
|
||
cost: context.purchasedGuildRate ? Math.ceil(amount / 2) : amount, noField: deployed,
|
||
state: physical.state, players: physical.players, sources: physical.sources };
|
||
}
|
||
return {
|
||
player: player.id,
|
||
tokenId: intent.tokenId,
|
||
source: source.id,
|
||
destination: destination.id,
|
||
amount: 1,
|
||
elite: 0,
|
||
cost: 1,
|
||
noField: deployed,
|
||
};
|
||
}
|
||
|
||
/** A deployed marker can be transported like one force between eligible
|
||
* Homeworlds, provisionally alongside physical forces from that same source.
|
||
* Advanced permits a native Homeworld endpoint; Basic remains foreign-only.
|
||
* Its denomination never enters the price or physical custody. */
|
||
export function quoteRicheseHomeworldMarkerTransfer(
|
||
context: HomeworldShipmentContext,
|
||
custody: HomeworldCustody,
|
||
intent: RicheseHomeworldMarkerTransferIntent,
|
||
noField: RicheseNoField,
|
||
) {
|
||
const worlds = homeworldForceGroups(context, custody);
|
||
requireMarker(record(intent) &&
|
||
(Object.keys(intent).sort().join(',') === 'destination,player,source,tokenId' ||
|
||
Object.keys(intent).sort().join(',') === 'destination,forces,player,source,tokenId') &&
|
||
typeof intent.player === 'string' && typeof intent.source === 'string' &&
|
||
typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
|
||
'Choose the current Homeworld marker, its source and another Homeworld.');
|
||
if ('forces' in intent) {
|
||
requireMarker(record(intent.forces) && Object.keys(intent.forces).sort().join(',') === 'elite,normal' &&
|
||
Number.isSafeInteger(intent.forces.normal) && Number.isSafeInteger(intent.forces.elite) &&
|
||
intent.forces.normal >= 0 && intent.forces.elite >= 0,
|
||
'Choose a valid physical normal and special counter group.');
|
||
}
|
||
requireMarker(context.players.every(seat => seat.ally === null ||
|
||
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
|
||
'Homeworld shipment needs valid seated alliance identities.');
|
||
const player = context.players.find(seat => seat.id === intent.player);
|
||
const source = worlds.find(world => world.id === intent.source);
|
||
const destination = worlds.find(world => world.id === intent.destination);
|
||
requireMarker(player?.faction === 'richese' && source && destination && source.id !== destination.id &&
|
||
(context.advanced || (source.native !== player.id && destination.native !== player.id)),
|
||
'Choose two different active Homeworlds; Basic marker transfers require foreign Homeworlds.');
|
||
const native = context.players.find(seat => seat.id === destination.native)!;
|
||
requireMarker(!(player.ally === native.id && native.ally === player.id),
|
||
'You cannot ship to your ally’s Homeworld.');
|
||
const marker = noField?.deployed;
|
||
requireMarker(marker?.controller === player.id && marker.tokenId === intent.tokenId &&
|
||
isHomeworldNoFieldLocation(marker.location) && marker.location.homeworld === source.id,
|
||
'The chosen concealed marker is no longer on its original Homeworld.');
|
||
let moved: RicheseNoField;
|
||
try {
|
||
moved = moveRicheseNoField(noField, intent.tokenId, { homeworld: destination.id });
|
||
} catch (error) {
|
||
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field transfer.');
|
||
}
|
||
const forces = intent.forces;
|
||
if (forces && forces.normal + forces.elite > 0) {
|
||
const physical = quoteHomeworldShipment(context, custody, {
|
||
player: player.id, destination: destination.id, sources: { [source.id]: forces },
|
||
});
|
||
const amount = 1 + physical.amount;
|
||
return { player: player.id, tokenId: intent.tokenId, source: source.id,
|
||
destination: destination.id, amount, elite: physical.elite,
|
||
cost: context.purchasedGuildRate ? Math.ceil(amount / 2) : amount, noField: moved,
|
||
state: physical.state, players: physical.players, sources: physical.sources };
|
||
}
|
||
return { player: player.id, tokenId: intent.tokenId, source: source.id,
|
||
destination: destination.id, amount: 1, elite: 0, cost: 1, noField: moved };
|
||
}
|