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
+13
-1
@@ -2697,7 +2697,19 @@ function policyActions(g: GameView): Action[] {
|
||||
score: option.normal + option.elite * (level > 0 ? 2.5 : 1) +
|
||||
(option.kept ? 1 + (option.kept.kind === 'elite' && level > 0 ? 0.5 : 0) : 0),
|
||||
})).sort((a, b) => b.score - a.score)
|
||||
.map(({ choice }) => ({ type: 'decision', event: d.event, choice }));
|
||||
.map(({ choice }) => {
|
||||
const option = d.options[choice];
|
||||
const normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0);
|
||||
const elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
|
||||
if (!d.reserveHomes || normal + elite === 0) return { type: 'decision', event: d.event, choice };
|
||||
const primary = d.reserveHomes.find(home => !home.secondary)!;
|
||||
const destinations: Record<string, { normal: number; elite: number }> = {};
|
||||
for (const home of d.reserveHomes) destinations[home.id] = {
|
||||
normal: home.id === primary.id ? normal : 0,
|
||||
elite: home.id === primary.id ? elite : 0,
|
||||
};
|
||||
return { type: 'decision', event: d.event, choice, destinations };
|
||||
});
|
||||
}
|
||||
if (d.kind === 'rihani') {
|
||||
if (d.stage === 'offer') return [{ type: 'decision', event: d.event, draw: true }];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
|
||||
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
|
||||
export type BureaucratPaymentSource = {
|
||||
@@ -39,7 +39,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
|
||||
nativeExpansionLeaderSkillsProfile(game))) || classicHomeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
|
||||
}
|
||||
export function bureaucratUsed(
|
||||
used: readonly BureaucratPaymentUse[],
|
||||
|
||||
@@ -9,8 +9,29 @@ export function discoveryModuleProfile(game: FactionModuleProfile): boolean {
|
||||
discoveryModeSupported(game);
|
||||
}
|
||||
|
||||
/** Original classic Homeworld/Discovery; live Arrakis-only token frames are expected. */
|
||||
export function classicHomeworldDiscoveryProfile(game: FactionModuleProfile): boolean {
|
||||
if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
game.expansions.length || game.players.length < 2 || game.players.length > 6 ||
|
||||
game.leaderSkills || game.nexusCards || game.techTokens || game.strongholdCards ||
|
||||
game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
game.nexusHarkonnenBetrayalPreview) return false;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The same roster/module contract during play; live Discovery frames are expected. */
|
||||
export function discoveryModeSupported(game: FactionModuleProfile): boolean {
|
||||
if (classicHomeworldDiscoveryProfile(game)) return true;
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
game.players.length < 2 || game.players.length > 6 ||
|
||||
(game.techTokens && game.players.length < 3) || game.expansions.length > 2 ||
|
||||
|
||||
+84
-32
@@ -1,7 +1,7 @@
|
||||
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
|
||||
import { nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, classicHomeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
canUsePlanetologistBattleSpecial,
|
||||
type BattleLeaderSkill,
|
||||
} from './leader-skill-combat';
|
||||
import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
||||
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
|
||||
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
|
||||
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
|
||||
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
|
||||
@@ -76,7 +76,7 @@ import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateH
|
||||
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
|
||||
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
|
||||
import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state';
|
||||
import { quoteHomeworldCustody } from './homeworld-custody';
|
||||
import { homeworldForceGroups, quoteHomeworldCustody } from './homeworld-custody';
|
||||
import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment';
|
||||
import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
import { quoteJunctionTransport } from './junction-transport';
|
||||
@@ -871,7 +871,7 @@ export type Decision =
|
||||
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
|
||||
| { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[]; reserveHomes?: { id: string; name: string; secondary: boolean }[] }
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
| {kind:'bureaucratPayment';player:string;event:string}
|
||||
@@ -2093,7 +2093,7 @@ export type Game = {
|
||||
signature: string;
|
||||
};
|
||||
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
|
||||
sukRescue?: { signature: string; completed: boolean };
|
||||
sukRescue?: { signature: string; completed: boolean; physical?: SukPhysicalRescueReceipt };
|
||||
ixSubstitution?: { signature: string; completed: boolean };
|
||||
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true };
|
||||
sandmaster?: { leader: string; key: string; before: number; after: number };
|
||||
@@ -9524,7 +9524,10 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or standalone E3 lobby without unrelated overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
const g = initializeSetupGameForAudit(state, false, false, false, true, false, false, state.expansions.length > 0);
|
||||
const homeworlds = classicHomeworldDiscoveryProfile(state);
|
||||
if (homeworlds) requireRule(state.homeworlds?.custody === null,
|
||||
'Start Homeworld Discovery with original undealt native custody.');
|
||||
const g = initializeSetupGameForAudit(state, homeworlds, false, false, true, false, false, state.expansions.length > 0);
|
||||
if (byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
|
||||
if (g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
|
||||
return g;
|
||||
@@ -9532,6 +9535,13 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
/** Gated development setup; never dispatched by a player action or room route. */
|
||||
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||
const homeworlds = classicHomeworldLeaderSkillsProfile(state);
|
||||
if (state.homeworlds) {
|
||||
requireRule(homeworlds && state.homeworlds.custody === null,
|
||||
'Homeworld Leader Skills require a fresh classic lobby without other optional modules or previews.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
if (state.discoveryEnabled) {
|
||||
requireRule(classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state) || classicNexusLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Nexus, Tech or Advanced Strongholds and no unrelated overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
@@ -9540,7 +9550,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
const g = structuredClone(state);
|
||||
g.leaderSkills = createLeaderSkills(random);
|
||||
const classicNexus = classicNexusLeaderSkillsProfile(g);
|
||||
const initialized = initializeSetupGameForAudit(g, false, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
|
||||
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
if (nativeDiscoveryLeaderSkillsProfile(initialized) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
|
||||
@@ -21643,8 +21653,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
g.pendingSukRescue = {
|
||||
event: g.lastBattleContext.event, turn: g.turn, player: winner.id,
|
||||
territory: b.territory, skill: quote.sukGraduate,
|
||||
commitment, pool: sukForcePool(winner, b.territory),
|
||||
cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '',
|
||||
commitment, pool: sukForcePool(g, winner, b.territory),
|
||||
cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner, g), losses: null, signature: '',
|
||||
...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}),
|
||||
...(fixedSukLoss ? { occupyCasualties: { ...structuredClone(casualtyCommitment), owner: casualtyCommitment.owner! } } : {}),
|
||||
};
|
||||
@@ -21786,9 +21796,10 @@ function settleWinnerCasualties(
|
||||
pending.losses = { ...choice };
|
||||
updateSukReceipt(g);
|
||||
const options = sukRescueOptions(pending.skill, pending.pool, choice, pending.eliteOrigins);
|
||||
if (options.length === 1) settleSukRescue(g, options[0], true);
|
||||
const reserveHomes = sukReserveHomes(g, p, to, options);
|
||||
if (options.length === 1 && !reserveHomes) settleSukRescue(g, options[0], true);
|
||||
else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event,
|
||||
territory: to, mode: pending.skill.mode, options };
|
||||
territory: to, mode: pending.skill.mode, options, ...(reserveHomes ? { reserveHomes } : {}) };
|
||||
return;
|
||||
}
|
||||
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
|
||||
@@ -21876,13 +21887,28 @@ function completeIxSubstitution(g: Game) {
|
||||
g.pendingIxSubstitution = null;
|
||||
if (g.lastBattleContext?.ixSubstitution) g.lastBattleContext.ixSubstitution.completed = true;
|
||||
}
|
||||
function sukForcePool(p: Player, territory: string): SukForceGroup[] {
|
||||
function sukForcePool(g: Game, p: Player, territory: string): SukForceGroup[] {
|
||||
if (territory.startsWith('homeworld:')) {
|
||||
const { normal, elite } = combatArmy(g, p.id, territory);
|
||||
return [{ key: territory, normal, elite }];
|
||||
}
|
||||
return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory)
|
||||
.map(([key, total]) => ({ key, normal: total - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }));
|
||||
}
|
||||
function sukPhysicalSignature(p: Player) {
|
||||
function sukPhysicalSignature(p: Player, g?: Game) {
|
||||
return JSON.stringify({ forces: p.forces, elites: p.elites ?? null,
|
||||
reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses });
|
||||
reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses,
|
||||
...(g?.homeworlds?.custody ? { homeworlds: g.homeworlds.custody } : {}) });
|
||||
}
|
||||
function sukReserveHomes(g: Game, player: Player, territory: string, options: readonly SukRescueOption[]): Extract<Decision, { kind: 'sukRescue' }>['reserveHomes'] {
|
||||
if (!g.homeworlds?.custody || !classicHomeworldLeaderSkillsProfile(g) ||
|
||||
!options.some(option => option.normal + option.elite > (option.kept ? 1 : 0))) return;
|
||||
const homes = homeworldRule(() => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!));
|
||||
if (homes.some(home => home.id === territory && home.native === player.id)) return;
|
||||
const native = homes.filter(home => home.native === player.id);
|
||||
if (native.length > 1) return native.map(home => ({
|
||||
id: home.id, name: combatLocationName(g, home.id), secondary: !!home.secondary,
|
||||
}));
|
||||
}
|
||||
function updateSukReceipt(g: Game) {
|
||||
const pending = g.pendingSukRescue!;
|
||||
@@ -21904,9 +21930,9 @@ function sukRescueIntegrity(g: Game) {
|
||||
context.result === 'normal' && context.winner === pending.player && context.territory === pending.territory &&
|
||||
context.sukRescue?.signature === pending.signature && !context.sukRescue.completed &&
|
||||
pending.signature === sukReceiptSignature(pending) &&
|
||||
pending.physical === sukPhysicalSignature(player) &&
|
||||
pending.physical === sukPhysicalSignature(player, g) &&
|
||||
(pending.eliteOrigins === undefined || pending.eliteOrigins === true && player.faction === 'ixians') &&
|
||||
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(player, pending.territory)) &&
|
||||
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(g, player, pending.territory)) &&
|
||||
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
|
||||
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
|
||||
JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) &&
|
||||
@@ -21916,7 +21942,8 @@ function sukRescueIntegrity(g: Game) {
|
||||
requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) &&
|
||||
decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event &&
|
||||
d.territory === pending.territory && d.mode === pending.skill.mode &&
|
||||
JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins))),
|
||||
JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins)) &&
|
||||
JSON.stringify(d.reserveHomes) === JSON.stringify(sukReserveHomes(g, player, pending.territory, d.options))),
|
||||
'The saved Suk Graduate rescue choices differ from the committed casualties.');
|
||||
} else {
|
||||
const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player);
|
||||
@@ -21931,10 +21958,10 @@ function sukRescueIntegrity(g: Game) {
|
||||
'The Suk Graduate rescue lost its casualty continuation.');
|
||||
}
|
||||
}
|
||||
function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
|
||||
function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, destinations?: SukReserveDestinations) {
|
||||
const pending = g.pendingSukRescue!;
|
||||
const player = getPlayer(g, pending.player);
|
||||
requireRule(pending.losses && pending.physical === sukPhysicalSignature(player),
|
||||
requireRule(pending.losses && pending.physical === sukPhysicalSignature(player, g),
|
||||
'The Suk Graduate rescue no longer has its committed physical counters.');
|
||||
const sardaukar = g.nexusSardaukarHistory?.find(record =>
|
||||
record.receipt.battle === pending.event && record.receipt.owner === pending.player);
|
||||
@@ -21943,8 +21970,20 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
|
||||
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options}) &&
|
||||
sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)),
|
||||
'The Suk rescue lost its original Nexus Sardaukar casualty allocation.');
|
||||
const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
|
||||
for (const group of quote.removed) {
|
||||
let physical: SukPhysicalRescueQuote | undefined;
|
||||
if (classicHomeworldLeaderSkillsProfile(g)) {
|
||||
try {
|
||||
physical = homeworldRule(() => quoteSukPhysicalRescue(homeworldLossContext(g), g.homeworlds!.custody!, {
|
||||
player: player.id, territory: pending.territory, skill: pending.skill,
|
||||
pool: pending.pool, losses: pending.losses!, option, destinations,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof SukPhysicalRescueError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
} else requireRule(destinations === undefined, 'Suk reserve destinations require the original Homeworld composition.');
|
||||
const quote = physical ? null : quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
|
||||
if (!physical || !pending.territory.startsWith('homeworld:')) for (const group of physical?.removed ?? quote!.removed) {
|
||||
player.forces[group.key] -= group.normal + group.elite;
|
||||
if (!player.forces[group.key]) delete player.forces[group.key];
|
||||
if (player.elites) {
|
||||
@@ -21952,12 +21991,18 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
|
||||
if (!player.elites.forces[group.key]) delete player.elites.forces[group.key];
|
||||
}
|
||||
}
|
||||
player.reserves += quote.reserves.normal + quote.reserves.elite;
|
||||
player.tanks += quote.tanks.normal + quote.tanks.elite;
|
||||
player.battleLosses += quote.tanks.normal + quote.tanks.elite;
|
||||
if (player.elites) {
|
||||
player.elites.reserves += quote.reserves.elite;
|
||||
player.elites.tanks += quote.tanks.elite;
|
||||
if (physical) {
|
||||
commitHomeworldResources(g, physical);
|
||||
if (pending.territory.startsWith('homeworld:')) g.homeworldBattleLoss = null;
|
||||
g.lastBattleContext!.sukRescue!.physical = physical.receipt;
|
||||
} else {
|
||||
player.reserves += quote!.reserves.normal + quote!.reserves.elite;
|
||||
player.tanks += quote!.tanks.normal + quote!.tanks.elite;
|
||||
player.battleLosses += quote!.tanks.normal + quote!.tanks.elite;
|
||||
if (player.elites) {
|
||||
player.elites.reserves += quote!.reserves.elite;
|
||||
player.elites.tanks += quote!.tanks.elite;
|
||||
}
|
||||
}
|
||||
if (sardaukar?.casualties) {
|
||||
sardaukar.casualties.outcome = 'complete';
|
||||
@@ -21965,9 +22010,16 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
|
||||
}
|
||||
g.pendingSukRescue = null;
|
||||
g.lastBattleContext!.sukRescue!.completed = true;
|
||||
log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
|
||||
const native = physical?.receipt.source === 'native-homeworld';
|
||||
const returned = native ? 0 : option.normal + option.elite - (option.kept ? 1 : 0);
|
||||
const tanks = physical?.receipt.tanks ?? quote!.tanks;
|
||||
const kept = native ? `${option.normal + option.elite} saved counters remained in their original Homeworld reserve pool`
|
||||
: option.kept ? option.kept.key.startsWith('homeworld:')
|
||||
? `1 remained on ${combatLocationName(g, option.kept.key)}` : `1 remained in sector ${splitLocation(option.kept.key).sector}`
|
||||
: 'none remained in the battle territory';
|
||||
log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${kept}, ${returned} returned to reserves, and ${tanks.normal + tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
|
||||
observeOccupation(g);
|
||||
if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote.eliteTanks!);
|
||||
if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote!.eliteTanks!);
|
||||
finishWinner(g, player, pending.territory, pending.cards);
|
||||
}
|
||||
function winnerDiscardSignature(pending: NonNullable<Game['pendingWinnerDiscards']>) {
|
||||
@@ -29036,10 +29088,10 @@ function applyActionInner(
|
||||
const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')];
|
||||
settleHomeworldExplosion(g, choice, false);
|
||||
} else if (decision.kind === 'sukRescue') {
|
||||
requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice'].includes(key)),
|
||||
requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice', 'destinations'].includes(key)),
|
||||
'Choose only the current Suk Graduate rescue.');
|
||||
const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')];
|
||||
settleSukRescue(g, option, false);
|
||||
settleSukRescue(g, option, false, action.destinations as SukReserveDestinations | undefined);
|
||||
} else if (decision.kind === 'rihani') {
|
||||
actRihani(g, p, decision, action);
|
||||
} else if (decision.kind === 'battleLosses') {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function homeworldGameIntegrity(g: Game): void {
|
||||
const beforeForces =
|
||||
g.status === 'lobby' ||
|
||||
(g.status === 'setup' &&
|
||||
(g.setupStage === 'prediction' || g.setupStage === 'traitors'));
|
||||
(g.setupStage === 'prediction' || (g.setupStage === 'leaderSkills' && !!g.leaderSkills) || g.setupStage === 'traitors'));
|
||||
if (beforeForces) {
|
||||
if (state.custody !== null)
|
||||
throw new HomeworldCustodyError(
|
||||
|
||||
@@ -376,6 +376,28 @@ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean
|
||||
return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game);
|
||||
}
|
||||
|
||||
/** Original classic Homeworld/Skills; custody and undealt components belong to setup. */
|
||||
export function classicHomeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
const players = game.players;
|
||||
if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.expansions.length ||
|
||||
!players || players.length < 2 || players.length > 6 ||
|
||||
game.nexusCards || game.discoveries || game.discoveryEnabled || game.discoveryStash ||
|
||||
game.greatMaker || game.techTokens || game.strongholdCards || game.ecazTreachery ||
|
||||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
|
||||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
|
||||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview ||
|
||||
game.mentatQuestionPreview || game.spiceBankerIncomePreview) return false;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shared by rule quotes, controls and minimal legal bot participation. */
|
||||
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
|
||||
return (noOtherLeaderSkillModules(game) &&
|
||||
@@ -383,5 +405,6 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
|
||||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
|
||||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
|
||||
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
|
||||
classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
|
||||
classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game) ||
|
||||
classicHomeworldLeaderSkillsProfile(game);
|
||||
}
|
||||
@@ -3820,6 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
).join(' '),
|
||||
steps: [
|
||||
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
|
||||
'Fresh private classic Homeworld prototypes may compose either all fourteen Leader Skills or Discovery seven cards/eight tokens, Basic/Advanced two through six unique base factions. Original setup and native placement remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes. That allocation is an implementation interpretation, not ordinary revival placement. Existing Maker, next-turn entry and nested shipment retain physical custody. Native expansion rosters and other overlays remain separate in this bounded checkpoint; public starts and specific pending interactions stay gated.',
|
||||
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
|
||||
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
|
||||
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
|
||||
@@ -4080,6 +4081,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
|
||||
steps: [
|
||||
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
|
||||
'A separate fresh classic Discovery entry retains Homeworlds alone in Basic/Advanced two through six unique base factions, original seven cards/eight tokens and native reserve placement. Maker consequences, typed reserve ride, next-turn entry and nested shipment use actual board/native groups. Other overlays and native expansion rosters remain separate in this bounded profile, not excluded from the full implementation goal.',
|
||||
'The fresh Leader Skills entry may explicitly add Discoveries with the original classic or supported native family decks, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Classic setups additionally accept --nexus-cards, retaining all12 and the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual starting hands and offered training are preserved. Physical free entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices use original sources. Homeworlds, independent variants and separate Banker/Mentat previews remain outside this composition.',
|
||||
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.',
|
||||
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.',
|
||||
@@ -4642,6 +4644,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'A separate fresh classic Leader Skills entry retains Homeworlds alone in Basic/Advanced two through six unique base factions and all fourteen original offers. Original native placement follows training. Normal/trained Suk uses actual native or visiting force pools; native saved counters remain at that home. External Emperor returns require exact ordinary/elite allocations to actual native homes, an implementation interpretation rather than the ordinary revival rule. Bureaucrat invoice redirection and minimal legal bots are connected. Native expansion rosters, other overlays and specific pending interactions remain separate.',
|
||||
'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
|
||||
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
|
||||
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
|
||||
|
||||
@@ -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