Files
Dune/game/homeworld-victory-reinforcement.ts
T
Quintine 28ba1618ba Implement Homeworld mechanics through Caladan reinforcement checkpoint
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.
2026-09-10 08:12:26 +10:00

248 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Game } from './engine';
import {
gameTerritories,
location,
MOBILE_STRONGHOLD,
splitLocation,
validLocation,
} from './board';
import { fighterCount } from './advisors';
import { territoryEntryBlock } from './occupancy';
import { homeworldContext } from './homeworld-game';
import {
HomeworldCustodyError,
homeworldForceGroups,
quoteHomeworldCustody,
} from './homeworld-custody';
import { homeworldPopulations } from './homeworld-population';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
import { lowGrummanRevealBlock } from './homeworld-collection';
export type HomeworldVictoryReinforcementContext = Pick<
Game,
| 'advanced'
| 'players'
| 'homeworlds'
| 'storm'
| 'mobileStronghold'
| 'moritaniTerror'
>;
export type HomeworldVictoryWitness = {
event: string;
turn: number;
player: string;
territory: string;
result: 'normal' | 'traitor';
};
export type HomeworldVictoryReinforcementDestination = {
id: string;
name: string;
territory?: string;
sector?: number;
homeworld?: string;
blocked: string | null;
};
export type HomeworldVictoryReinforcementQuote = HomeworldVictoryWitness & {
population: number;
survivors: number;
amount: 0 | 1;
blocked: string | null;
destinations: HomeworldVictoryReinforcementDestination[];
};
function validCount(n: unknown): n is number {
return Number.isSafeInteger(n) && (n as number) >= 0 && (n as number) <= 20;
}
function invalid(message: string): never {
throw new HomeworldCustodyError(message);
}
/** Printed Caladan high face; E3 p9's continuous native threshold applies at
* actual use, after winner casualties. The caller proves this original victory
* against its battle receipt and owns ordering, single use and entry reactions.
* Face Dance priority is not selected here. */
export function quoteHomeworldVictoryReinforcement(
g: HomeworldVictoryReinforcementContext,
victory: HomeworldVictoryWitness,
): HomeworldVictoryReinforcementQuote {
if (
!victory ||
typeof victory !== 'object' ||
Array.isArray(victory) ||
Object.keys(victory).sort().join(',') !==
'event,player,result,territory,turn' ||
typeof victory.event !== 'string' ||
!victory.event ||
typeof victory.player !== 'string' ||
!victory.player ||
typeof victory.territory !== 'string' ||
!victory.territory ||
!Number.isSafeInteger(victory.turn) ||
victory.turn < 1 ||
!['normal', 'traitor'].includes(victory.result)
)
invalid(
'Caladan reinforcement requires one original winning battle receipt.',
);
const owner = g.players.find((p) => p.id === victory.player);
if (!owner || g.players.filter((p) => p.id === victory.player).length !== 1)
invalid('The original winning faction must be seated.');
const board = gameTerritories(g).find((t) => t.id === victory.territory);
const worlds = g.homeworlds?.custody
? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody)
: [];
const home = worlds.find((world) => world.id === victory.territory);
if (!board && !home)
invalid('Choose the actual battle territory or active Homeworld.');
let survivors = 0;
if (home) {
const forces = home.forces[owner.id];
survivors = forces ? forces.normal + forces.elite : 0;
} else {
for (const [key, amount] of Object.entries(owner.forces)) {
const at = splitLocation(key);
if (
!validLocation(at.territory, at.sector) ||
location(at.territory, at.sector) !== key ||
!validCount(amount)
)
invalid(
'Caladan reinforcement requires valid surviving physical forces.',
);
if (at.territory === victory.territory) survivors += amount;
}
}
if (!validCount(survivors))
invalid('The surviving army exceeds its physical force inventory.');
const population = g.homeworlds?.custody
? (homeworldPopulations(homeworldContext(g), g.homeworlds.custody).find(
(world) => world.native === owner.id && world.card === 'caladan',
)?.population ?? 0)
: 0;
const quote: HomeworldVictoryReinforcementQuote = {
...victory,
population,
survivors,
amount: 0,
blocked: null,
destinations: [],
};
if (owner.faction !== 'atreides' || !g.homeworlds?.custody) return quote;
if (population < 6)
return {
...quote,
blocked:
'Caladan requires at least six native reserves for victory reinforcement.',
};
if (survivors === 0)
return {
...quote,
blocked: 'An Atreides force must survive at the battle location.',
};
// All native reserves already occupy Caladan. Moving one within that same
// custody pool cannot add a counter or return one from the Tanks.
if (home?.native === owner.id)
return {
...quote,
blocked:
'The native reserve forces are already on Caladan; no additional force can be placed there.',
};
quote.amount = 1;
if (home) {
const native = g.players.find((seat) => seat.id === home.native)!;
quote.destinations.push({
id: home.id,
name: HOMEWORLD_CARDS.find(
(card) =>
card.faction === native.faction &&
(card.id === 'salusa_secundus') === home.secondary,
)!.name,
homeworld: home.id,
blocked:
owner.ally === native.id && native.ally === owner.id
? 'You cannot reinforce on your ally’s Homeworld.'
: null,
});
return quote;
}
if (!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18)
invalid('Caladan reinforcement requires the current storm sector.');
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
const moritani = g.players.find((seat) => seat.faction === 'moritani');
quote.destinations = board!.sectors.map((sector) => {
let blocked: string | null = null;
if (board!.id === MOBILE_STRONGHOLD)
blocked =
'Caladan reinforcement in the Hidden Mobile Stronghold awaits its entry-classification ruling.';
if (!blocked && sector === g.storm)
blocked =
'Caladan reinforcement into a sector in storm awaits its placement-permission ruling.';
blocked ??= territoryEntryBlock(g.players, owner.id, board!.id);
if (!blocked && g.advanced && bg && fighterCount(bg, board!.id) > 0)
blocked =
'Intrusion after Caladan reinforcement awaits an entry-classification ruling.';
if (
!blocked &&
moritani &&
owner.id !== moritani.ally &&
!lowGrummanRevealBlock(g, moritani.id, 1) &&
g.moritaniTerror?.tokens.some(
(token) => token.status === 'placed' && token.location === board!.id,
)
)
blocked =
'Terror after Caladan reinforcement awaits an entry-classification ruling.';
return {
id: location(board!.id, sector),
name: `${board!.name}${sector ? ` · sector ${sector}` : ''}`,
territory: board!.id,
sector,
blocked,
};
});
return quote;
}
/** One actual native normal counter; never a revival, fee or ordinary shipment. */
export function quoteHomeworldVictoryReinforcementDestination(
g: HomeworldVictoryReinforcementContext,
victory: HomeworldVictoryWitness,
destination: string,
) {
const quote = quoteHomeworldVictoryReinforcement(g, victory);
const selected = quote.destinations.find(
(choice) => choice.id === destination,
);
if (quote.amount !== 1 || !selected || selected.blocked)
invalid(
quote.blocked ??
selected?.blocked ??
'Choose one eligible destination at the won battle location.',
);
const forces = { normal: 1, elite: 0 };
const context = homeworldContext(g);
const transfer = selected.homeworld
? quoteHomeworldCustody(context, g.homeworlds!.custody!, [
{
homeworld: 'homeworld:atreides',
player: victory.player,
withdraw: forces,
deposit: { normal: 0, elite: 0 },
},
{
homeworld: selected.homeworld,
player: victory.player,
withdraw: { normal: 0, elite: 0 },
deposit: forces,
},
])
: quoteNativeReserveWithdrawal(
context,
g.homeworlds!.custody!,
victory.player,
forces,
);
return { quote, selected, transfer };
}