Files
Dune/game/homeworld-victory-reinforcement.ts
T

226 lines
7.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,
splitLocation,
validLocation,
} from './board';
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';
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.');
quote.destinations = board!.sectors.map((sector) => {
let blocked: string | null = null;
if (sector === g.storm)
blocked =
'You cannot add the Caladan reinforcement to a sector in storm.';
blocked ??= territoryEntryBlock(g.players, owner.id, board!.id);
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 };
}