226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
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 };
|
||
}
|