Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
164 lines
5.0 KiB
TypeScript
164 lines
5.0 KiB
TypeScript
import {
|
|
HomeworldCustodyError,
|
|
homeworldForceGroups,
|
|
quoteHomeworldCustody,
|
|
type HomeworldCustody,
|
|
type HomeworldCustodyContext,
|
|
type HomeworldForces,
|
|
type HomeworldReserveSeat,
|
|
} from './homeworld-custody';
|
|
|
|
export type HomeworldCombatLossPlayer = HomeworldReserveSeat & {
|
|
tanks: number;
|
|
eliteTanks: number;
|
|
battleLosses: number;
|
|
/** Arrakis counters only; foreign Homeworld armies are held in custody. */
|
|
boardForces: HomeworldForces;
|
|
};
|
|
export type HomeworldCombatLossContext = Omit<
|
|
HomeworldCustodyContext,
|
|
'players'
|
|
> & {
|
|
players: readonly HomeworldCombatLossPlayer[];
|
|
};
|
|
export type HomeworldCombatLossRequest = {
|
|
location: string;
|
|
player: string;
|
|
losses: HomeworldForces;
|
|
};
|
|
export class HomeworldCombatLossError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'HomeworldCombatLossError';
|
|
}
|
|
}
|
|
function requireLoss(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new HomeworldCombatLossError(message);
|
|
}
|
|
const whole = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
function typed(value: unknown): asserts value is HomeworldForces {
|
|
requireLoss(
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
Object.keys(value).length === 2 &&
|
|
Object.hasOwn(value, 'normal') &&
|
|
Object.hasOwn(value, 'elite') &&
|
|
whole((value as HomeworldForces).normal) &&
|
|
whole((value as HomeworldForces).elite) &&
|
|
(value as HomeworldForces).normal + (value as HomeworldForces).elite <=
|
|
20,
|
|
'Homeworld combat needs exact physical normal and special counter counts.',
|
|
);
|
|
}
|
|
function conserved(
|
|
context: HomeworldCombatLossContext,
|
|
custody: HomeworldCustody,
|
|
) {
|
|
const groups = homeworldForceGroups(context, custody);
|
|
for (const player of context.players) {
|
|
typed(player.boardForces);
|
|
requireLoss(
|
|
whole(player.tanks) &&
|
|
player.tanks <= 20 &&
|
|
whole(player.eliteTanks) &&
|
|
player.eliteTanks <= player.tanks &&
|
|
whole(player.battleLosses),
|
|
'Homeworld combat needs valid Tanks and battle-loss totals.',
|
|
);
|
|
const visitors = groups.reduce(
|
|
(sum, home) => {
|
|
const pool =
|
|
home.native !== player.id && Object.hasOwn(home.forces, player.id)
|
|
? home.forces[player.id]
|
|
: { normal: 0, elite: 0 };
|
|
return {
|
|
normal: sum.normal + pool.normal,
|
|
elite: sum.elite + pool.elite,
|
|
};
|
|
},
|
|
{ normal: 0, elite: 0 },
|
|
);
|
|
const supply =
|
|
player.faction === 'emperor'
|
|
? 5
|
|
: player.faction === 'fremen'
|
|
? 3
|
|
: player.faction === 'ixians'
|
|
? 7
|
|
: 0;
|
|
requireLoss(
|
|
player.reserves +
|
|
player.tanks +
|
|
player.boardForces.normal +
|
|
player.boardForces.elite +
|
|
visitors.normal +
|
|
visitors.elite ===
|
|
20 &&
|
|
player.eliteReserves +
|
|
player.eliteTanks +
|
|
player.boardForces.elite +
|
|
visitors.elite ===
|
|
supply,
|
|
'Homeworld combat must conserve all 20 physical counters and their special identities.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Detached physical settlement only. The battle resolver authorizes the exact
|
|
* casualty choice. Zero casualties are valid; printed strength never becomes
|
|
* a counter, and no Arrakis force keys, occupation effects or payments occur. */
|
|
export function quoteHomeworldCombatLoss(
|
|
context: HomeworldCombatLossContext,
|
|
custody: HomeworldCustody,
|
|
request: HomeworldCombatLossRequest,
|
|
) {
|
|
try {
|
|
conserved(context, custody);
|
|
requireLoss(
|
|
!!request && typeof request === 'object' && !Array.isArray(request),
|
|
'Choose a Homeworld combat loss.',
|
|
);
|
|
typed(request.losses);
|
|
const quote = quoteHomeworldCustody(context, custody, [
|
|
{
|
|
homeworld: request.location,
|
|
player: request.player,
|
|
withdraw: request.losses,
|
|
deposit: { normal: 0, elite: 0 },
|
|
},
|
|
]);
|
|
const total = request.losses.normal + request.losses.elite;
|
|
const players = context.players.map(
|
|
(player, index): HomeworldCombatLossPlayer => ({
|
|
...quote.players[index],
|
|
tanks: player.tanks + (player.id === request.player ? total : 0),
|
|
eliteTanks:
|
|
player.eliteTanks +
|
|
(player.id === request.player ? request.losses.elite : 0),
|
|
battleLosses:
|
|
player.battleLosses + (player.id === request.player ? total : 0),
|
|
boardForces: { ...player.boardForces },
|
|
}),
|
|
);
|
|
conserved({ advanced: context.advanced, players }, quote.state);
|
|
const receipt = quote.receipts[0];
|
|
return {
|
|
custody: quote.state,
|
|
players,
|
|
receipt: {
|
|
location: receipt.homeworld,
|
|
player: receipt.player,
|
|
before: { ...receipt.before },
|
|
after: { ...receipt.after },
|
|
losses: { ...request.losses },
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof HomeworldCustodyError)
|
|
throw new HomeworldCombatLossError(error.message);
|
|
throw error;
|
|
}
|
|
}
|