428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import {
|
|
nexusGuildSecretAllyCanAct,
|
|
nexusGuildSecretAllyQuote,
|
|
} from './nexus-guild-secret-ally-options';
|
|
import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
|
|
import type { Action, GameView } from './engine';
|
|
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
|
import {
|
|
HomeworldCustodyError,
|
|
type HomeworldCustody,
|
|
type HomeworldForces,
|
|
} from './homeworld-custody';
|
|
import { quoteHomeworldShipment } from './homeworld-shipment';
|
|
import { quoteRicheseHomeworldMarkerShipment, quoteRicheseHomeworldMarkerTransfer } from './richese-homeworld-marker-shipment';
|
|
import { isHomeworldNoFieldLocation } from './richese-no-field';
|
|
|
|
export type HomeworldShipmentSources = Record<string, HomeworldForces>;
|
|
export function homeworldName(view: GameView, id: string): string {
|
|
const home = view.homeworlds?.worlds?.find((world) => world.id === id);
|
|
return (
|
|
HOMEWORLD_CARDS.find((card) => card.id === home?.card)?.name ?? 'Homeworld'
|
|
);
|
|
}
|
|
|
|
/** Owner-only marker quote. The face stays private; the marker counts as one
|
|
* regardless of denomination, plus any selected physical companions. */
|
|
export function richeseHomeworldMarkerChoice(
|
|
view: GameView,
|
|
destination: string,
|
|
tokenId: string,
|
|
allyPayment = 0,
|
|
source?: string,
|
|
forces?: HomeworldForces,
|
|
) {
|
|
const result: { blocked: string | null; amount: number; cost: number;
|
|
ownPayment: number; allyPayment: number; action: Action | null } = {
|
|
blocked: null, amount: 1, cost: 1, ownPayment: 1 - allyPayment, allyPayment, action: null,
|
|
};
|
|
const option = view.homeworldShipment, worlds = view.homeworlds?.worlds;
|
|
const me = view.players.find(player => player.id === view.me);
|
|
const info = view.richeseNoField;
|
|
if (view.nexusCards || view.leaderSkills || view.discoveryEnabled || view.discoveries ||
|
|
view.techTokens || view.strongholdCards) {
|
|
result.blocked = 'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.';
|
|
return result;
|
|
}
|
|
if (!option || !worlds?.length || !me || me.faction !== 'richese' ||
|
|
info?.owner !== me.id || !info.private || !info.event ||
|
|
view.roomControl?.paused || view.roomControl?.closed || !nexusGuildShipmentAvailable(view)) {
|
|
result.blocked = 'Use your available Richese No-Field during your shipment turn.';
|
|
return result;
|
|
}
|
|
if (option.blocked || (source === undefined && !info.canShip)) {
|
|
result.blocked = option.blocked ?? info.shipBlock ?? 'No-Field shipment is not available now.';
|
|
return result;
|
|
}
|
|
const context = {
|
|
advanced: view.advanced,
|
|
nativeGuildRateCanceled: view.guildRateCanceled,
|
|
purchasedGuildRate: view.karamaShipping?.player === view.me,
|
|
players: view.players.map(player => ({ id: player.id, faction: player.faction,
|
|
reserves: player.reserves, eliteReserves: player.elites?.reserves ?? 0, ally: player.ally ?? null })),
|
|
};
|
|
const custody: HomeworldCustody = { salusa: null, visitors: {} };
|
|
for (const world of worlds) {
|
|
if (world.secondary) custody.salusa = { ...world.forces[world.native] };
|
|
const visitors = Object.entries(world.forces).filter(([owner]) => owner !== world.native);
|
|
if (visitors.length) custody.visitors[world.id] = Object.fromEntries(
|
|
visitors.map(([owner, forces]) => [owner, { ...forces }]));
|
|
}
|
|
try {
|
|
if (source === undefined) {
|
|
const quote = quoteRicheseHomeworldMarkerShipment(context, custody,
|
|
{ player: me.id, destination, tokenId, ...(forces === undefined ? {} : { forces }) }, info.private);
|
|
result.amount = quote.amount;
|
|
result.cost = quote.cost;
|
|
} else {
|
|
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
|
|
{ player: me.id, destination, tokenId, source, ...(forces === undefined ? {} : { forces }) }, info.private);
|
|
result.amount = quote.amount;
|
|
result.cost = quote.cost;
|
|
}
|
|
result.ownPayment = result.cost - allyPayment;
|
|
if (!Number.isSafeInteger(allyPayment) || allyPayment < 0 || allyPayment > result.cost ||
|
|
allyPayment > (me.ally ? view.aid.available : 0)) {
|
|
result.blocked = 'Choose an allied contribution within the current pledge and shipment price.';
|
|
return result;
|
|
}
|
|
if (result.ownPayment > (me.spice ?? 0)) {
|
|
result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
|
|
return result;
|
|
}
|
|
const sources = forces && forces.normal + forces.elite > 0
|
|
? { [source ?? 'homeworld:richese']: forces } : {};
|
|
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
|
|
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
|
|
} catch (error) {
|
|
if (!(error instanceof HomeworldCustodyError)) throw error;
|
|
result.blocked = error.message;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Only public physical counters and the actor's own available funding enter
|
|
* the quote. Foreign garrisons are never relabelled as native reserves. */
|
|
export function homeworldShipmentChoice(
|
|
view: GameView,
|
|
destination: string,
|
|
sources: HomeworldShipmentSources,
|
|
allyPayment = 0,
|
|
nexus?: string,
|
|
) {
|
|
const result: {
|
|
blocked: string | null;
|
|
cost: number;
|
|
amount: number;
|
|
elite: number;
|
|
ownPayment: number;
|
|
allyPayment: number;
|
|
action: Action | null;
|
|
} = {
|
|
blocked: null,
|
|
cost: 0,
|
|
amount: 0,
|
|
elite: 0,
|
|
ownPayment: 0,
|
|
allyPayment,
|
|
action: null,
|
|
};
|
|
const option = view.homeworldShipment;
|
|
const worlds = view.homeworlds?.worlds;
|
|
const me = view.players.find((player) => player.id === view.me);
|
|
if (
|
|
!option ||
|
|
!worlds?.length ||
|
|
!me ||
|
|
view.status !== 'playing' ||
|
|
view.phase !== 5 ||
|
|
view.active !== view.me ||
|
|
!nexusGuildShipmentAvailable(view)
|
|
) {
|
|
result.blocked = 'Use Homeworld shipment during your unused shipment turn.';
|
|
return result;
|
|
}
|
|
if (option.blocked) {
|
|
result.blocked = option.blocked;
|
|
return result;
|
|
}
|
|
if (
|
|
view.guildTransportCanceled &&
|
|
me.faction === 'guild' &&
|
|
destination === 'homeworld:guild'
|
|
) {
|
|
result.blocked =
|
|
'The native Guild return permission is canceled; a purchased price cannot restore this route.';
|
|
return result;
|
|
}
|
|
if (nexus !== undefined && view.karamaShipping?.player === view.me) {
|
|
result.blocked =
|
|
'A purchased Karama price is already active; do not combine it with Guild Nexus Secret Ally.';
|
|
return result;
|
|
}
|
|
const context = {
|
|
advanced: view.advanced,
|
|
nativeGuildRateCanceled: view.guildRateCanceled,
|
|
purchasedGuildRate: view.karamaShipping?.player === view.me,
|
|
players: view.players.map((player) => ({
|
|
id: player.id,
|
|
faction: player.faction,
|
|
reserves: player.reserves,
|
|
eliteReserves: player.elites?.reserves ?? 0,
|
|
ally: player.ally ?? null,
|
|
})),
|
|
};
|
|
const custody: HomeworldCustody = { salusa: null, visitors: {} };
|
|
for (const world of worlds) {
|
|
if (world.secondary) custody.salusa = { ...world.forces[world.native] };
|
|
const visitors = Object.entries(world.forces).filter(
|
|
([owner]) => owner !== world.native,
|
|
);
|
|
if (visitors.length)
|
|
custody.visitors[world.id] = Object.fromEntries(
|
|
visitors.map(([owner, forces]) => [owner, { ...forces }]),
|
|
);
|
|
}
|
|
try {
|
|
const quote = quoteHomeworldShipment(context, custody, {
|
|
player: me.id,
|
|
destination,
|
|
sources,
|
|
});
|
|
const nexusQuote =
|
|
nexus === undefined
|
|
? null
|
|
: nexusGuildSecretAllyQuote(view, destination, quote.amount);
|
|
if (
|
|
nexus !== undefined &&
|
|
(!nexusQuote || nexus !== view.nexusGuildSecretAlly?.event)
|
|
) {
|
|
result.blocked = 'Choose a current Guild Secret Ally shipment offer.';
|
|
return result;
|
|
}
|
|
const cost = nexusQuote?.cost ?? quote.cost;
|
|
result.cost = cost;
|
|
result.amount = quote.amount;
|
|
result.elite = quote.elite;
|
|
result.ownPayment = cost - allyPayment;
|
|
if (
|
|
!Number.isSafeInteger(allyPayment) ||
|
|
allyPayment < 0 ||
|
|
allyPayment > cost ||
|
|
allyPayment > (me.ally ? view.aid.available : 0)
|
|
)
|
|
result.blocked =
|
|
'Choose an allied contribution within the current pledge and shipment price.';
|
|
else if (result.ownPayment > (me.spice ?? 0))
|
|
result.blocked =
|
|
'Your spice and selected allied contribution cannot pay this shipment.';
|
|
else
|
|
result.action = {
|
|
type: 'homeworldShip',
|
|
event: option.event,
|
|
destination,
|
|
sources,
|
|
allyPayment,
|
|
...(nexus === undefined ? {} : { nexus }),
|
|
};
|
|
} catch (error) {
|
|
if (!(error instanceof HomeworldCustodyError)) throw error;
|
|
result.blocked = error.message;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Public tactical candidates retain a native defense and spice for the battle.
|
|
* Higher levels reinforce contested invasions and budget for native strength. */
|
|
export function homeworldShipmentActions(
|
|
view: GameView,
|
|
level: number,
|
|
): Action[] {
|
|
const me = view.players.find((player) => player.id === view.me);
|
|
const worlds = view.homeworlds?.worlds;
|
|
if (
|
|
!me ||
|
|
!worlds?.length ||
|
|
!view.homeworldShipment ||
|
|
view.homeworldShipment.blocked ||
|
|
view.phase !== 5 ||
|
|
view.active !== me.id ||
|
|
!nexusGuildShipmentAvailable(view)
|
|
)
|
|
return [];
|
|
const useNexus =
|
|
view.karamaShipping?.player !== view.me &&
|
|
nexusGuildSecretAllyCanAct(view);
|
|
const difficulty = Math.max(0, Math.min(3, level));
|
|
const budget = Math.max(
|
|
0,
|
|
(me.spice ?? 0) + view.aid.available - [2, 3, 3, 2][difficulty],
|
|
);
|
|
const sources = worlds.filter(
|
|
(world) =>
|
|
(world.forces[me.id]?.normal ?? 0) + (world.forces[me.id]?.elite ?? 0) >
|
|
0,
|
|
);
|
|
const targets = worlds
|
|
.filter(
|
|
(world) =>
|
|
(world.native !== me.id || me.faction === 'guild') &&
|
|
!(
|
|
view.guildTransportCanceled &&
|
|
me.faction === 'guild' &&
|
|
world.native === me.id
|
|
) &&
|
|
world.native !== me.ally,
|
|
)
|
|
.map((world) => {
|
|
const enemies = Object.entries(world.forces).filter(
|
|
([owner]) => owner !== me.id && owner !== me.ally,
|
|
);
|
|
const enemy = enemies.reduce(
|
|
(sum, [, forces]) =>
|
|
sum + forces.normal + forces.elite * (view.advanced ? 2 : 1),
|
|
0,
|
|
);
|
|
const native = world.forces[world.native];
|
|
const strength =
|
|
enemy +
|
|
(world.native !== me.id && native.normal + native.elite > 0
|
|
? world.nativeBattleStrength
|
|
: 0);
|
|
const own = world.forces[me.id];
|
|
const existing =
|
|
(own?.normal ?? 0) + (own?.elite ?? 0) * (view.advanced ? 2 : 1);
|
|
return {
|
|
world,
|
|
strength,
|
|
existing,
|
|
score: (existing > 0 && enemy > 0 ? 10 + difficulty * 3 : 0) - strength,
|
|
};
|
|
})
|
|
.sort((a, b) => b.score - a.score || a.world.id.localeCompare(b.world.id));
|
|
const sourceGroups = sources.map((source) => [source]);
|
|
const imperial = sources.filter((source) => source.native === me.id);
|
|
if (view.advanced && me.faction === 'emperor' && imperial.length === 2)
|
|
sourceGroups.push(imperial);
|
|
const actions: Action[] = [];
|
|
for (const target of targets) {
|
|
if (
|
|
target.world.native === me.id &&
|
|
target.world.side === 'high' &&
|
|
target.strength === 0
|
|
)
|
|
continue;
|
|
const required = Math.max(
|
|
1,
|
|
target.strength + [0, 1, 2, 3][difficulty] - target.existing,
|
|
);
|
|
for (const group of sourceGroups) {
|
|
if (group.some((source) => source.id === target.world.id)) continue;
|
|
const pools = group.map((source) => {
|
|
const force = source.forces[me.id];
|
|
const keep =
|
|
source.native !== me.id
|
|
? 0
|
|
: source.secondary
|
|
? Math.min(2, force.elite)
|
|
: [3, 3, 4, 3][difficulty];
|
|
return {
|
|
source,
|
|
force,
|
|
capacity: Math.max(0, force.normal + force.elite - keep),
|
|
};
|
|
});
|
|
const amount = Math.min(
|
|
pools.reduce((sum, pool) => sum + pool.capacity, 0),
|
|
[3, 5, 7, 9][difficulty],
|
|
budget *
|
|
((me.faction === 'guild' && !view.guildRateCanceled) ||
|
|
view.karamaShipping?.player === view.me ||
|
|
useNexus
|
|
? 2
|
|
: 1),
|
|
Math.max([1, 3, 4, 5][difficulty], required),
|
|
);
|
|
if (amount <= 0) continue;
|
|
let remaining = amount;
|
|
const selection: HomeworldShipmentSources = {};
|
|
for (const pool of [...pools].sort((a, b) =>
|
|
difficulty > 0
|
|
? b.force.elite - a.force.elite
|
|
: a.source.id.localeCompare(b.source.id),
|
|
)) {
|
|
const count = Math.min(remaining, pool.capacity);
|
|
if (!count) continue;
|
|
const availableElite = pool.source.secondary
|
|
? Math.max(0, pool.force.elite - 2)
|
|
: pool.force.elite;
|
|
const elite =
|
|
difficulty > 0
|
|
? Math.min(count, availableElite)
|
|
: Math.max(0, count - pool.force.normal);
|
|
selection[pool.source.id] = { normal: count - elite, elite };
|
|
remaining -= count;
|
|
}
|
|
const elite = Object.values(selection).reduce(
|
|
(sum, forces) => sum + forces.elite,
|
|
0,
|
|
);
|
|
const strength = amount + (view.advanced ? elite : 0);
|
|
if (
|
|
strength + target.existing <
|
|
target.strength + (difficulty > 0 ? 1 : 0)
|
|
)
|
|
continue;
|
|
const nexus =
|
|
useNexus && amount > 1 ? view.nexusGuildSecretAlly!.event : undefined;
|
|
const initial = homeworldShipmentChoice(
|
|
view,
|
|
target.world.id,
|
|
selection,
|
|
0,
|
|
nexus,
|
|
);
|
|
const quote = homeworldShipmentChoice(
|
|
view,
|
|
target.world.id,
|
|
selection,
|
|
Math.max(0, initial.cost - (me.spice ?? 0)),
|
|
nexus,
|
|
);
|
|
if (
|
|
quote.action &&
|
|
!actions.some(
|
|
(action) => JSON.stringify(action) === JSON.stringify(quote.action),
|
|
)
|
|
)
|
|
actions.push(quote.action);
|
|
}
|
|
}
|
|
// Minimal legal participation: offer one private marker route through the
|
|
// same quote used by the human control. Faction strategy remains deferred.
|
|
if (me.faction === 'richese' && view.richeseNoField?.owner === me.id && view.richeseNoField.private) {
|
|
const privateState = view.richeseNoField.private, deployed = privateState.deployed;
|
|
const source = deployed && isHomeworldNoFieldLocation(deployed.location) ? deployed.location.homeworld : undefined;
|
|
const token = source ? privateState.tokens.find(candidate => candidate.id === deployed?.tokenId)
|
|
: privateState.tokens.find(candidate => candidate.id !== privateState.lastShipped);
|
|
if (token) for (const target of targets) {
|
|
const quote = richeseHomeworldMarkerChoice(view, target.world.id, token.id,
|
|
Math.max(0, 1 - (me.spice ?? 0)), source);
|
|
if (quote.action) {
|
|
actions.push(quote.action);
|
|
const pool = source ? view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
|
|
{ normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
|
|
if (pool && (pool.normal > 0 || pool.elite > 0)) {
|
|
const forces = { normal: pool.normal > 0 ? 1 : 0, elite: pool.normal > 0 ? 0 : 1 };
|
|
const initial = richeseHomeworldMarkerChoice(view, target.world.id, token.id, 0, source, forces);
|
|
const mixed = richeseHomeworldMarkerChoice(view, target.world.id, token.id,
|
|
Math.max(0, initial.cost - (me.spice ?? 0)), source, forces);
|
|
if (mixed.action) actions.push(mixed.action);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return actions;
|
|
}
|