145 lines
5.0 KiB
TypeScript
145 lines
5.0 KiB
TypeScript
import {
|
||
HomeworldCustodyError,
|
||
homeworldForceGroups,
|
||
quoteHomeworldCustody,
|
||
type HomeworldCustody,
|
||
type HomeworldCustodyContext,
|
||
type HomeworldForces,
|
||
type HomeworldReserveSeat,
|
||
} from './homeworld-custody';
|
||
|
||
export type HomeworldShipmentContext = Omit<
|
||
HomeworldCustodyContext,
|
||
'players'
|
||
> & {
|
||
players: (HomeworldReserveSeat & { ally: string | null })[];
|
||
/** Only the native Guild faction rate; independent transport grants survive. */
|
||
nativeGuildRateCanceled?: boolean;
|
||
/** Independently purchased ordinary Karama price; never a route permission. */
|
||
purchasedGuildRate?: boolean;
|
||
};
|
||
export type HomeworldShipmentIntent = {
|
||
player: string;
|
||
destination: string;
|
||
sources: Record<string, HomeworldForces>;
|
||
};
|
||
const record = (value: unknown): value is Record<string, unknown> =>
|
||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||
const whole = (value: unknown): value is number =>
|
||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||
function requireShipment(
|
||
condition: unknown,
|
||
message: string,
|
||
): asserts condition {
|
||
if (!condition) throw new HomeworldCustodyError(message);
|
||
}
|
||
|
||
/** E3 world-to-world transport: use the actual native or foreign source pool.
|
||
* Guild may also return a foreign garrison to its own world; other own-world
|
||
* exceptions need separately established permission. The caller owns timing, usage,
|
||
* funding, bank payment, responses and arrival effects. A purchased ordinary
|
||
* Karama price takes precedence over the native Guild half rate or its
|
||
* cancellation; an alliance alone does not grant either price. Independent
|
||
* Nexus and Junction prices belong to their own grants. */
|
||
export function quoteHomeworldShipment(
|
||
context: HomeworldShipmentContext,
|
||
custody: HomeworldCustody,
|
||
intent: HomeworldShipmentIntent,
|
||
) {
|
||
// Validate the entire public custody before using any selected source.
|
||
const worlds = homeworldForceGroups(context, custody);
|
||
requireShipment(
|
||
context.players.every(
|
||
(p) =>
|
||
p.ally === null ||
|
||
(typeof p.ally === 'string' &&
|
||
p.ally !== p.id &&
|
||
context.players.some((other) => other.id === p.ally)),
|
||
),
|
||
'Homeworld shipment needs valid seated alliance identities.',
|
||
);
|
||
requireShipment(
|
||
record(intent) &&
|
||
Object.keys(intent).length === 3 &&
|
||
typeof intent.player === 'string' &&
|
||
typeof intent.destination === 'string' &&
|
||
record(intent.sources),
|
||
'Choose a seated shipper, a destination Homeworld and typed source groups.',
|
||
);
|
||
const player = context.players.find((p) => p.id === intent.player);
|
||
const destination = worlds.find((w) => w.id === intent.destination);
|
||
requireShipment(
|
||
player && destination,
|
||
'Choose an active Homeworld and seated shipper.',
|
||
);
|
||
const native = context.players.find((p) => p.id === destination.native)!;
|
||
requireShipment(
|
||
native.id !== player.id || player.faction === 'guild',
|
||
'Shipment to your own Homeworld needs its specific return permission.',
|
||
);
|
||
requireShipment(
|
||
!(player.ally === native.id && native.ally === player.id),
|
||
'You cannot ship to your ally’s Homeworld.',
|
||
);
|
||
const sources = Object.entries(intent.sources);
|
||
requireShipment(
|
||
sources.length === 1 ||
|
||
(sources.length === 2 &&
|
||
context.advanced &&
|
||
player.faction === 'emperor' &&
|
||
sources.every(([id]) =>
|
||
['homeworld:emperor', 'homeworld:emperor:salusa'].includes(id),
|
||
)),
|
||
'Use one source Homeworld, or combine Emperor’s own Kaitain and Salusa forces in Advanced play.',
|
||
);
|
||
let normal = 0;
|
||
let elite = 0;
|
||
for (const [id, forces] of sources) {
|
||
requireShipment(
|
||
id !== destination.id && worlds.some((w) => w.id === id),
|
||
'Shipment needs a distinct active source Homeworld.',
|
||
);
|
||
requireShipment(
|
||
record(forces) &&
|
||
Object.keys(forces).length === 2 &&
|
||
whole(forces.normal) &&
|
||
whole(forces.elite) &&
|
||
forces.normal + forces.elite > 0 &&
|
||
forces.normal + forces.elite <= 20,
|
||
'Each source needs a positive physical normal and special counter group.',
|
||
);
|
||
normal += forces.normal;
|
||
elite += forces.elite;
|
||
}
|
||
const amount = normal + elite;
|
||
requireShipment(
|
||
amount > 0 && amount <= 20,
|
||
'A shipment cannot exceed the faction’s twenty physical counters.',
|
||
);
|
||
const transferred = quoteHomeworldCustody(context, custody, [
|
||
...sources.map(([homeworld, forces]) => ({
|
||
homeworld,
|
||
player: player.id,
|
||
withdraw: { normal: forces.normal, elite: forces.elite },
|
||
deposit: { normal: 0, elite: 0 },
|
||
})),
|
||
{
|
||
homeworld: destination.id,
|
||
player: player.id,
|
||
withdraw: { normal: 0, elite: 0 },
|
||
deposit: { normal, elite },
|
||
},
|
||
]);
|
||
return {
|
||
...transferred,
|
||
amount,
|
||
elite,
|
||
cost:
|
||
context.purchasedGuildRate ||
|
||
(player.faction === 'guild' && !context.nativeGuildRateCanceled)
|
||
? Math.ceil(amount / 2)
|
||
: amount,
|
||
sources: transferred.receipts.filter((r) => r.homeworld !== destination.id),
|
||
};
|
||
}
|