Compose classic Homeworld Skills and Discovery with physical custody
This commit is contained in:
1 parent
4a3bd8fe90
commit
e2dcebca35
24 files changed
+1426
-54
No files matched your search
@@ -1,5 +1,8 @@
|
||||
import type { Casualties, CombatForces } from './combat';
|
||||
import type { BattleLeaderSkill } from './leader-skill-combat';
|
||||
import { homeworldForceGroups, quoteHomeworldCustody, type HomeworldCustody, type HomeworldForces } from './homeworld-custody';
|
||||
import { quoteHomeworldCombatLoss, type HomeworldCombatLossContext, type HomeworldCombatLossPlayer } from './homeworld-combat-loss';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
|
||||
export type SukGraduateSkill = { leader: string; mode: 'normal' | 'skilled' };
|
||||
export type SukForceGroup = { key: string; normal: number; elite: number };
|
||||
@@ -29,6 +32,34 @@ export type SukRescueReceipt = {
|
||||
signature: string;
|
||||
};
|
||||
|
||||
/** Non-revival placement choice for counters returning from Arrakis or a foreign
|
||||
* Homeworld. It never relocates rescued counters already in native reserves. */
|
||||
export type SukReserveDestinations = NativeReserveSelections;
|
||||
export type SukPhysicalRescueRequest = {
|
||||
player: string;
|
||||
territory: string;
|
||||
skill: SukGraduateSkill;
|
||||
pool: readonly SukForceGroup[];
|
||||
losses: Casualties;
|
||||
option: SukRescueOption;
|
||||
destinations?: SukReserveDestinations;
|
||||
};
|
||||
export type SukPhysicalRescueReceipt = {
|
||||
player: string;
|
||||
territory: string;
|
||||
source: 'arrakis' | 'native-homeworld' | 'visitor-homeworld';
|
||||
saved: HomeworldForces;
|
||||
kept: SukRescueOption['kept'];
|
||||
destinations: Record<string, HomeworldForces>;
|
||||
tanks: HomeworldForces;
|
||||
};
|
||||
export type SukPhysicalRescueQuote = {
|
||||
custody: HomeworldCustody;
|
||||
players: HomeworldCombatLossPlayer[];
|
||||
removed: SukForceGroup[];
|
||||
receipt: SukPhysicalRescueReceipt;
|
||||
};
|
||||
|
||||
export function sukReceiptSignature(receipt: SukRescueReceipt): string {
|
||||
const { signature: _signature, ...value } = receipt;
|
||||
return JSON.stringify(value);
|
||||
@@ -132,3 +163,104 @@ export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceG
|
||||
tanks: { normal: losses.normal - option.normal, elite: losses.elite - option.elite },
|
||||
};
|
||||
}
|
||||
|
||||
export class SukPhysicalRescueError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SukPhysicalRescueError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Detached classic Homeworld settlement, including Arrakis rescue returns.
|
||||
* Printed Suk saves only real casualties. Native Homeworld forces already ARE
|
||||
* reserves, so every saved native counter remains in its original pool.
|
||||
*
|
||||
* For external returns the single native home is unambiguous. The application
|
||||
* asks the player for a typed allocation when two homes exist; this is not the
|
||||
* printed Emperor revival placement rule or a ruling on other return routes. */
|
||||
export function quoteSukPhysicalRescue(
|
||||
context: HomeworldCombatLossContext,
|
||||
custody: HomeworldCustody,
|
||||
request: SukPhysicalRescueRequest,
|
||||
): SukPhysicalRescueQuote {
|
||||
const homes = homeworldForceGroups(context, custody);
|
||||
const nativeHomes = homes.filter(home => home.native === request.player);
|
||||
if (!nativeHomes.length) throw new SukPhysicalRescueError('Suk Graduate needs a seated native reserve owner.');
|
||||
// Reuse the existing complete twenty-counter and special-identity validator.
|
||||
quoteHomeworldCombatLoss(context, custody, {
|
||||
location: nativeHomes[0].id, player: request.player, losses: { normal: 0, elite: 0 },
|
||||
});
|
||||
const rescue = quoteSukRescue(request.skill, request.pool, request.losses, request.option);
|
||||
const home = homes.find(candidate => candidate.id === request.territory);
|
||||
if (request.territory.startsWith('homeworld:') && !home)
|
||||
throw new SukPhysicalRescueError('Suk Graduate needs its original canonical battle Homeworld.');
|
||||
const native = home?.native === request.player;
|
||||
if (home) {
|
||||
const army = home.forces[request.player] ?? { normal: 0, elite: 0 };
|
||||
if (request.pool.length !== 1 || request.pool[0].key !== home.id ||
|
||||
request.pool[0].normal !== army.normal || request.pool[0].elite !== army.elite)
|
||||
throw new SukPhysicalRescueError('Suk Graduate needs the exact native or visitor battle army.');
|
||||
} else if (request.pool.some(group => group.key.startsWith('homeworld:')))
|
||||
throw new SukPhysicalRescueError('Arrakis Suk Graduate casualties cannot borrow Homeworld pools.');
|
||||
|
||||
const returned = native ? { normal: 0, elite: 0 } : rescue.reserves;
|
||||
const destinations: Record<string, HomeworldForces> = {};
|
||||
if (request.destinations !== undefined) {
|
||||
if (!request.destinations || typeof request.destinations !== 'object' || Array.isArray(request.destinations))
|
||||
throw new SukPhysicalRescueError('Choose a typed Suk Graduate native-home allocation.');
|
||||
for (const [location, forces] of Object.entries(request.destinations)) {
|
||||
if (!nativeHomes.some(candidate => candidate.id === location) ||
|
||||
!forces || typeof forces !== 'object' || Array.isArray(forces) ||
|
||||
Object.keys(forces).length !== 2 ||
|
||||
!Object.hasOwn(forces, 'normal') || !Object.hasOwn(forces, 'elite') ||
|
||||
![forces.normal, forces.elite].every(value => Number.isSafeInteger(value) && value >= 0))
|
||||
throw new SukPhysicalRescueError('Suk Graduate reserve destinations must be exact typed native Homeworlds.');
|
||||
destinations[location] = { normal: forces.normal, elite: forces.elite };
|
||||
}
|
||||
const total = Object.values(destinations).reduce((sum, forces) => ({
|
||||
normal: sum.normal + forces.normal, elite: sum.elite + forces.elite,
|
||||
}), { normal: 0, elite: 0 });
|
||||
if (total.normal !== returned.normal || total.elite !== returned.elite)
|
||||
throw new SukPhysicalRescueError('Suk Graduate destinations must match only the counters actually returning to reserves.');
|
||||
} else if (returned.normal + returned.elite) {
|
||||
if (nativeHomes.length !== 1)
|
||||
throw new SukPhysicalRescueError('Choose the Suk Graduate return allocation between your native Homeworlds.');
|
||||
destinations[nativeHomes[0].id] = { ...returned };
|
||||
}
|
||||
const removed = native
|
||||
? [{ key: request.territory, ...rescue.tanks }]
|
||||
: rescue.removed;
|
||||
const removedTotal = removed.reduce((sum, group) => ({
|
||||
normal: sum.normal + group.normal, elite: sum.elite + group.elite,
|
||||
}), { normal: 0, elite: 0 });
|
||||
const transaction = quoteHomeworldCustody(context, custody, [
|
||||
...(home ? [{ homeworld: home.id, player: request.player,
|
||||
withdraw: removedTotal, deposit: { normal: 0, elite: 0 } }] : []),
|
||||
...Object.entries(destinations).filter(([, forces]) => forces.normal + forces.elite > 0)
|
||||
.map(([homeworld, deposit]) => ({
|
||||
homeworld, player: request.player, withdraw: { normal: 0, elite: 0 }, deposit,
|
||||
})),
|
||||
]);
|
||||
const players: HomeworldCombatLossPlayer[] = context.players.map((player, index) => ({
|
||||
...transaction.players[index],
|
||||
tanks: player.tanks + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
|
||||
eliteTanks: player.eliteTanks + (player.id === request.player ? rescue.tanks.elite : 0),
|
||||
battleLosses: player.battleLosses + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
|
||||
boardForces: !home && player.id === request.player
|
||||
? { normal: player.boardForces.normal - removedTotal.normal, elite: player.boardForces.elite - removedTotal.elite }
|
||||
: { ...player.boardForces },
|
||||
}));
|
||||
quoteHomeworldCombatLoss({ advanced: context.advanced, players }, transaction.state, {
|
||||
location: nativeHomes[0].id, player: request.player, losses: { normal: 0, elite: 0 },
|
||||
});
|
||||
return {
|
||||
custody: transaction.state, players, removed,
|
||||
receipt: {
|
||||
player: request.player, territory: request.territory,
|
||||
source: home ? native ? 'native-homeworld' : 'visitor-homeworld' : 'arrakis',
|
||||
saved: { normal: request.option.normal, elite: request.option.elite },
|
||||
kept: request.option.kept ? { ...request.option.kept } : null,
|
||||
destinations, tanks: { ...rescue.tanks },
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user