Files
Dune/game/guild-homeworld-shipment-options.ts
T

273 lines
8.5 KiB
TypeScript

import { nexusGuildShipmentAvailable } from './nexus-guild-cunning-options';
import type { Action, GameView } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { gameTerritories, MOBILE_LOCATION, splitLocation } from './board';
import {
HomeworldCustodyError,
type HomeworldCustody,
} from './homeworld-custody';
import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
import type { HomeworldShipmentSources } from './homeworld-shipment-options';
/** The quote uses public physical counters, never opposing private resources. */
export function guildHomeworldShipmentChoice(
view: GameView,
destination: string,
sources: HomeworldShipmentSources,
allyPayment = 0,
) {
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 me = view.players.find((player) => player.id === view.me);
const option = view.guildHomeworldShipment;
const worlds = view.homeworlds?.worlds;
if (
!me ||
me.faction !== 'guild' ||
!option ||
!worlds?.length ||
view.status !== 'playing' ||
view.phase !== 5 ||
view.active !== me.id ||
!nexusGuildShipmentAvailable(view)
) {
result.blocked = 'Use Guild transport during your unused shipment turn.';
return result;
}
if (option.blocked) {
result.blocked = option.blocked;
return result;
}
if (view.guildTransportCanceled) {
result.blocked =
'The native Guild transport permission is canceled; a purchased price cannot restore this route.';
return result;
}
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 = quoteGuildHomeworldShipment(
{
advanced: view.advanced,
nativeGuildRateCanceled: view.guildRateCanceled,
purchasedGuildRate: view.karamaShipping?.player === view.me,
storm: view.storm,
mobileStronghold: view.mobileStronghold?.location ?? null,
discoveries: view.discoveries,
players: view.players.map((player) => ({
id: player.id,
faction: player.faction,
reserves: player.reserves,
eliteReserves: player.elites?.reserves ?? 0,
ally: player.ally ?? null,
})),
// Only the acting Guild map is needed. Concealed rival counters never enter.
board: {
[me.id]: { forces: me.forces, eliteForces: me.elites?.forces ?? {} },
},
},
custody,
{ player: me.id, destination, sources },
);
result.cost = quote.cost;
result.amount = quote.amount;
result.elite = quote.elite;
result.ownPayment = quote.cost - allyPayment;
if (
!Number.isSafeInteger(allyPayment) ||
allyPayment < 0 ||
allyPayment > quote.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: 'guildHomeworldShip',
event: option.event,
destination,
sources,
allyPayment,
};
} catch (error) {
if (!(error instanceof HomeworldCustodyError)) throw error;
result.blocked = error.message;
}
return result;
}
/** Gather actual Guild counters by one Arrakis territory, retaining sectors. */
export function guildHomeworldShipmentOrigins(view: GameView) {
const me = view.players.find((player) => player.id === view.me);
if (!me || me.faction !== 'guild') return [];
return gameTerritories(view).flatMap((territory) => {
const pools = Object.entries(me.forces).flatMap(([key, amount]) => {
if (splitLocation(key).territory !== territory.id || amount <= 0)
return [];
const elite = me.elites?.forces[key] ?? 0;
return [
{
key,
sector: key === MOBILE_LOCATION ? null : splitLocation(key).sector,
normal: amount - elite,
elite,
},
];
});
return pools.length ? [{ territory, pools }] : [];
});
}
/** Return exposed desert armies to a depleted Junction or reinforce attainable
* invasions. Keep stronghold defenders and spice collectors on Arrakis. */
export function guildHomeworldShipmentActions(
view: GameView,
level: number,
): Action[] {
const me = view.players.find((player) => player.id === view.me);
if (
!me ||
me.faction !== 'guild' ||
!view.guildHomeworldShipment ||
view.guildHomeworldShipment.blocked ||
view.guildTransportCanceled ||
!nexusGuildShipmentAvailable(view) ||
view.phase !== 5 ||
view.active !== me.id
)
return [];
const difficulty = Math.max(0, Math.min(3, level));
const budget = Math.max(
0,
(me.spice ?? 0) +
(me.ally ? view.aid.available : 0) -
[2, 3, 3, 2][difficulty],
);
const origins = guildHomeworldShipmentOrigins(view);
const actions: Action[] = [];
const targets = (view.homeworlds?.worlds ?? [])
.filter((world) => world.native !== me.ally)
.map((world) => {
const enemy = Object.entries(world.forces)
.filter(([id]) => id !== me.id && id !== me.ally)
.reduce(
(sum, [, pool]) =>
sum + pool.normal + pool.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);
return { world, strength, existing };
})
.sort((a, b) =>
a.world.native === me.id
? -1
: b.world.native === me.id
? 1
: a.strength - b.strength,
);
for (const target of targets) {
if (
target.world.native === me.id &&
target.world.side === 'high' &&
target.strength === 0
)
continue;
for (const origin of origins) {
const eligible = origin.pools.filter(
(pool) => pool.sector !== view.storm,
);
const total = eligible.reduce(
(sum, pool) => sum + pool.normal + pool.elite,
0,
);
const retain =
origin.territory.type === 'stronghold'
? [2, 3, 4, 4][difficulty]
: (view.spice[origin.territory.id] ?? 0) > 0
? Math.min(total, Math.ceil(view.spice[origin.territory.id] / 2))
: 0;
const desired =
target.world.native === me.id
? Math.max(
1,
(HOMEWORLD_CARDS.find((card) => card.id === target.world.card)
?.high.reserves.min ?? 5) - target.existing,
)
: Math.max(
[1, 3, 4, 5][difficulty],
target.strength + [0, 1, 2, 3][difficulty] - target.existing,
);
const amount = Math.min(
total - retain,
budget *
(view.karamaShipping?.player === view.me || !view.guildRateCanceled
? 2
: 1),
[3, 5, 7, 9][difficulty],
desired,
);
if (
amount <= 0 ||
(target.world.native !== me.id &&
amount + target.existing < target.strength + (difficulty > 0 ? 1 : 0))
)
continue;
let remaining = amount;
const sources: HomeworldShipmentSources = {};
for (const pool of eligible) {
const count = Math.min(remaining, pool.normal + pool.elite);
if (!count) continue;
sources[pool.key] = { normal: count, elite: 0 };
remaining -= count;
}
const initial = guildHomeworldShipmentChoice(
view,
target.world.id,
sources,
);
const quote = guildHomeworldShipmentChoice(
view,
target.world.id,
sources,
Math.max(0, initial.cost - (me.spice ?? 0)),
);
if (quote.action) actions.push(quote.action);
}
}
return actions;
}