455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
import type { Action, GameView } from './engine';
|
|
import {
|
|
gameTerritories,
|
|
MOBILE_LOCATION,
|
|
MOBILE_STRONGHOLD,
|
|
splitLocation,
|
|
} from './board';
|
|
import { arrivalAsAdvisor, forceCount } from './advisors';
|
|
import { territoryEntryBlock } from './occupancy';
|
|
import {
|
|
HomeworldCustodyError,
|
|
type HomeworldCustody,
|
|
} from './homeworld-custody';
|
|
import { quoteJunctionTransport } from './junction-transport';
|
|
import {
|
|
homeworldName,
|
|
type HomeworldShipmentSources,
|
|
} from './homeworld-shipment-options';
|
|
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
|
|
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
|
|
import { atomicsShipmentBlocked } from './moritani-atomics';
|
|
|
|
export function junctionTransportOrigins(view: GameView) {
|
|
const me = view.players.find((player) => player.id === view.me);
|
|
if (!me) return [];
|
|
const worlds = (view.homeworlds?.worlds ?? []).flatMap((world) => {
|
|
const pool = world.forces[me.id];
|
|
return pool && pool.normal + pool.elite > 0
|
|
? [
|
|
{
|
|
id: world.id,
|
|
name: homeworldName(view, world.id),
|
|
native: world.native === me.id,
|
|
kind: 'homeworld' as const,
|
|
pools: [
|
|
{
|
|
key: world.id,
|
|
name: homeworldName(view, world.id),
|
|
sector: null as number | null,
|
|
...pool,
|
|
},
|
|
],
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
const board = gameTerritories(view).flatMap((territory) => {
|
|
const pools = Object.entries(me.forces).flatMap(([key, amount]) => {
|
|
if (amount <= 0 || splitLocation(key).territory !== territory.id)
|
|
return [];
|
|
const sector = key === MOBILE_LOCATION ? null : splitLocation(key).sector;
|
|
return [
|
|
{
|
|
key,
|
|
name: `${territory.name}${sector ? ` sector ${sector}` : ''}`,
|
|
sector,
|
|
normal: amount - (me.elites?.forces[key] ?? 0),
|
|
elite: me.elites?.forces[key] ?? 0,
|
|
},
|
|
];
|
|
});
|
|
return pools.length
|
|
? [
|
|
{
|
|
id: territory.id,
|
|
name: territory.name,
|
|
native: false,
|
|
kind: 'arrakis' as const,
|
|
pools,
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
const imperial = worlds.filter((world) => world.native);
|
|
return [
|
|
...worlds,
|
|
...(me.faction === 'emperor' && view.advanced && imperial.length === 2
|
|
? [
|
|
{
|
|
id: 'imperial-worlds',
|
|
name: 'Kaitain and Salusa Secundus',
|
|
native: true,
|
|
kind: 'homeworld' as const,
|
|
pools: imperial.flatMap((world) => world.pools),
|
|
},
|
|
]
|
|
: []),
|
|
...board,
|
|
];
|
|
}
|
|
|
|
export function junctionTransportDestinations(view: GameView) {
|
|
const me = view.players.find((player) => player.id === view.me);
|
|
if (!me) return [];
|
|
return [
|
|
...(view.homeworlds?.worlds ?? [])
|
|
.filter((world) => world.native !== me.ally)
|
|
.map((world) => ({
|
|
id: world.id,
|
|
name: `${homeworldName(view, world.id)}${world.native === me.id ? ' · own Homeworld' : ''}`,
|
|
})),
|
|
...gameTerritories(view)
|
|
.filter((t) => t.id !== MOBILE_STRONGHOLD || me.faction === 'ixians')
|
|
.flatMap((t) =>
|
|
t.sectors.map((sector) => ({
|
|
id: `${t.id}:${sector}`,
|
|
name: `${t.name}${sector ? ` · sector ${sector}` : ''}${sector && sector === view.storm ? ' · storm' : ''}`,
|
|
})),
|
|
),
|
|
];
|
|
}
|
|
|
|
/** Reconstruct public world custody and read only the recipient's own balance.
|
|
* Ordinary arrival/advisor and promise helpers are shared with the engine. */
|
|
export function junctionTransportChoice(
|
|
view: GameView,
|
|
destination: string,
|
|
sources: HomeworldShipmentSources,
|
|
allyPayment = 0,
|
|
) {
|
|
const result: {
|
|
blocked: string | null;
|
|
cost: number;
|
|
amount: number;
|
|
elite: number;
|
|
ownPayment: number;
|
|
allyPayment: number;
|
|
advisors: boolean;
|
|
action: Action | null;
|
|
forfeitPurchasedRate: boolean;
|
|
} = {
|
|
blocked: null,
|
|
cost: 0,
|
|
amount: 0,
|
|
elite: 0,
|
|
ownPayment: 0,
|
|
allyPayment,
|
|
advisors: false,
|
|
action: null,
|
|
forfeitPurchasedRate: false,
|
|
};
|
|
const me = view.players.find((player) => player.id === view.me);
|
|
const option = view.junctionTransport;
|
|
const offer = option?.offer;
|
|
if (
|
|
!me ||
|
|
!option ||
|
|
!offer ||
|
|
option.recipient !== me.id ||
|
|
offer.recipient !== me.id ||
|
|
offer.owner !== option.owner ||
|
|
offer.turn !== view.turn ||
|
|
view.status !== 'playing' ||
|
|
view.phase !== 5 ||
|
|
view.active !== me.id ||
|
|
me.shipped
|
|
) {
|
|
result.blocked =
|
|
'Guild must offer Junction transport during your unused shipment turn.';
|
|
return result;
|
|
}
|
|
result.forfeitPurchasedRate = view.karamaShipping?.player === me.id;
|
|
if (option.blocked) {
|
|
result.blocked = option.blocked;
|
|
return result;
|
|
}
|
|
const custody: HomeworldCustody = { salusa: null, visitors: {} };
|
|
for (const world of view.homeworlds?.worlds ?? []) {
|
|
if (world.secondary) custody.salusa = { ...world.forces[world.native] };
|
|
const visitors = Object.entries(world.forces).filter(
|
|
([id]) => id !== world.native,
|
|
);
|
|
if (visitors.length)
|
|
custody.visitors[world.id] = Object.fromEntries(
|
|
visitors.map(([id, forces]) => [id, { ...forces }]),
|
|
);
|
|
}
|
|
try {
|
|
const quote = quoteJunctionTransport(
|
|
{
|
|
advanced: view.advanced,
|
|
storm: view.storm,
|
|
mobileStronghold: view.mobileStronghold?.location ?? null,
|
|
discoveries: view.discoveries,
|
|
players: view.players.map((p) => ({
|
|
id: p.id,
|
|
faction: p.faction,
|
|
reserves: p.reserves,
|
|
eliteReserves: p.elites?.reserves ?? 0,
|
|
ally: p.ally ?? null,
|
|
})),
|
|
board: {
|
|
[me.id]: {
|
|
forces: me.forces,
|
|
eliteForces: me.elites?.forces ?? {},
|
|
advisors: me.advisors,
|
|
},
|
|
},
|
|
},
|
|
custody,
|
|
{
|
|
player: me.id,
|
|
sponsor: offer.owner,
|
|
destination,
|
|
sources,
|
|
rate: offer.rate,
|
|
},
|
|
);
|
|
Object.assign(result, {
|
|
cost: quote.cost,
|
|
amount: quote.amount,
|
|
elite: quote.elite,
|
|
ownPayment: quote.cost - allyPayment,
|
|
});
|
|
const arrival =
|
|
quote.destinationKind === 'arrakis' ? splitLocation(destination) : null;
|
|
if (arrival) {
|
|
result.advisors = arrivalAsAdvisor(
|
|
view,
|
|
me,
|
|
arrival.territory,
|
|
quote.originKind === 'arrakis' ? quote.origin : undefined,
|
|
);
|
|
if(view.advisorTransportMatchingDenied?.length){
|
|
const group=Object.fromEntries(quote.sources.map(source=>[source.key,
|
|
source.before.normal+source.before.elite-source.after.normal-source.after.elite]));
|
|
try{
|
|
quoteAdvisorTransportMatching({...view,worlds:view.homeworlds?.worlds??undefined,
|
|
advisorTransportMatchingOpportunity:view.advisorTransportMatchingScopes?.junction},
|
|
{producer:'junction',owner:me.id,origin:quote.originKind==='homeworld'?Object.keys(group).sort()[0]:quote.origin,
|
|
destination:arrival.territory,sector:arrival.sector,advisors:result.advisors,group});
|
|
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;result.blocked=error.message;}
|
|
}
|
|
const entryBlock = territoryEntryBlock(
|
|
view.players,
|
|
me.id,
|
|
arrival.territory,
|
|
result.advisors,
|
|
);
|
|
if (atomicsShipmentBlocked(view.moritaniAtomics, arrival.territory))
|
|
result.blocked = 'Atomics Aftermath permanently blocks shipments into this territory.';
|
|
else if (arrival.territory === MOBILE_STRONGHOLD && me.faction !== 'ixians')
|
|
result.blocked =
|
|
'Only Ixians may ship directly into the mobile stronghold.';
|
|
else if (arrival.sector !== 0 && arrival.sector === view.storm)
|
|
result.blocked = 'That sector is in storm.';
|
|
else if (entryBlock) result.blocked = entryBlock;
|
|
else if (
|
|
quote.originKind === 'arrakis' &&
|
|
!result.advisors &&
|
|
me.advisors?.[quote.origin]?.lockedTurn === view.turn &&
|
|
view.players.some(
|
|
(p) => p.id !== me.id && forceCount(p, arrival.territory),
|
|
)
|
|
)
|
|
result.blocked = 'New advisors cannot become fighters this turn.';
|
|
}
|
|
const nativeOrigin =
|
|
quote.originKind === 'homeworld' &&
|
|
Object.keys(sources).every((key) =>
|
|
view.homeworlds?.worlds?.some(
|
|
(w) => w.id === key && w.native === me.id,
|
|
),
|
|
);
|
|
const promiseShipment =
|
|
arrival && nativeOrigin
|
|
? { territory: arrival.territory, amount: quote.amount }
|
|
: null;
|
|
if (
|
|
!result.blocked &&
|
|
liveShipmentPromises(view.shipmentPromises ?? [], me.id, view.turn).some(
|
|
(promise) =>
|
|
matchesShipment(promise, promiseShipment) !== promise.answer,
|
|
)
|
|
)
|
|
result.blocked =
|
|
'This transport conflicts with your binding shipment answer.';
|
|
if (
|
|
!result.blocked &&
|
|
(!Number.isSafeInteger(allyPayment) ||
|
|
allyPayment < 0 ||
|
|
allyPayment > quote.cost ||
|
|
allyPayment > (me.ally ? view.aid.available : 0))
|
|
)
|
|
result.blocked =
|
|
'Choose an allied contribution within the pledge and shipment price.';
|
|
if (!result.blocked && result.ownPayment > (me.spice ?? 0))
|
|
result.blocked =
|
|
'Your spice and selected allied contribution cannot pay this transport.';
|
|
if (!result.blocked) {
|
|
result.action = {
|
|
type: 'junctionShip',
|
|
event: option.event,
|
|
offer: offer.event,
|
|
destination,
|
|
sources,
|
|
allyPayment,
|
|
};
|
|
if (result.forfeitPurchasedRate) result.action.forfeitPurchasedRate = true;
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof HomeworldCustodyError)) throw error;
|
|
result.blocked = error.message;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Sponsors offer once per opportunity. Recipients compare public targets,
|
|
* preserve useful armies and increase their battle reserve with difficulty. */
|
|
export function junctionTransportActions(
|
|
view: GameView,
|
|
level: number,
|
|
): Action[] {
|
|
const me = view.players.find((p) => p.id === view.me);
|
|
const option = view.junctionTransport;
|
|
if (!me || !option) return [];
|
|
const difficulty = Math.max(0, Math.min(3, Math.trunc(level)));
|
|
if (me.id === option.owner) {
|
|
const rate =
|
|
me.ally === option.recipient || difficulty === 0 ? 'half' : 'full';
|
|
return option.canOffer && !option.offer
|
|
? [{ type: 'offerJunctionTransport', event: option.offerEvent, rate }]
|
|
: [];
|
|
}
|
|
if (
|
|
me.id !== option.recipient ||
|
|
!option.offer ||
|
|
option.blocked ||
|
|
me.shipped ||
|
|
view.active !== me.id ||
|
|
view.phase !== 5
|
|
)
|
|
return [];
|
|
const budget = Math.max(
|
|
0,
|
|
(me.spice ?? 0) +
|
|
(me.ally ? view.aid.available : 0) -
|
|
[1, 2, 3, 3][difficulty],
|
|
);
|
|
const spiceAt = (territory: string) =>
|
|
Object.entries(view.spice).reduce(
|
|
(sum, [key, amount]) =>
|
|
sum + (splitLocation(key).territory === territory ? amount : 0),
|
|
0,
|
|
);
|
|
const targets = junctionTransportDestinations(view)
|
|
.map((target) => {
|
|
const world = view.homeworlds?.worlds?.find((w) => w.id === target.id);
|
|
const location = world ? null : splitLocation(target.id);
|
|
const t = location
|
|
? gameTerritories(view).find((t) => t.id === location.territory)
|
|
: null;
|
|
const own = world
|
|
? (world.forces[me.id]?.normal ?? 0) + (world.forces[me.id]?.elite ?? 0)
|
|
: forceCount(me, location!.territory);
|
|
const enemy = world
|
|
? Object.entries(world.forces)
|
|
.filter(([id]) => id !== me.id && id !== me.ally)
|
|
.reduce(
|
|
(n, [, pool]) =>
|
|
n + pool.normal + pool.elite * (view.advanced ? 2 : 1),
|
|
0,
|
|
) +
|
|
(world.native !== me.id &&
|
|
world.forces[world.native].normal + world.forces[world.native].elite >
|
|
0
|
|
? world.nativeBattleStrength
|
|
: 0)
|
|
: view.players
|
|
.filter((p) => p.id !== me.id && p.id !== me.ally)
|
|
.reduce((n, p) => n + forceCount(p, location!.territory), 0);
|
|
const defend =
|
|
world?.native === me.id && (world.side === 'low' || enemy > 0);
|
|
const score =
|
|
(defend
|
|
? 20
|
|
: world?.native === me.id
|
|
? -20
|
|
: t?.type === 'stronghold'
|
|
? 12
|
|
: world
|
|
? 5
|
|
: spiceAt(location!.territory) > 0
|
|
? 7
|
|
: -8) +
|
|
(own > 0 && enemy > 0 ? 8 : 0) -
|
|
enemy;
|
|
return { ...target, own, enemy, score };
|
|
})
|
|
.filter((t) => t.score > 0)
|
|
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
const actions: Action[] = [];
|
|
const seen = new Set<string>();
|
|
for (const target of targets)
|
|
for (const origin of junctionTransportOrigins(view)) {
|
|
const pools = origin.pools.filter(
|
|
(pool) =>
|
|
pool.sector === null ||
|
|
pool.sector === 0 ||
|
|
pool.sector !== view.storm,
|
|
);
|
|
const total = pools.reduce((n, pool) => n + pool.normal + pool.elite, 0);
|
|
const t = gameTerritories(view).find((t) => t.id === origin.id);
|
|
const retain = origin.native
|
|
? [3, 4, 5, 6][difficulty]
|
|
: t?.type === 'stronghold'
|
|
? [2, 3, 4, 4][difficulty]
|
|
: spiceAt(origin.id) > 0
|
|
? Math.min(total, Math.ceil(spiceAt(origin.id) / 2))
|
|
: 0;
|
|
const desired = Math.max(
|
|
[2, 3, 4, 5][difficulty],
|
|
target.enemy + [0, 1, 2, 3][difficulty] - target.own,
|
|
);
|
|
for (
|
|
let amount = Math.min(
|
|
total - retain,
|
|
desired,
|
|
[4, 6, 8, 10][difficulty],
|
|
);
|
|
amount > 0;
|
|
amount--
|
|
) {
|
|
if (amount + target.own < target.enemy + (difficulty > 0 ? 1 : 0))
|
|
break;
|
|
let left = amount;
|
|
const sources: HomeworldShipmentSources = {};
|
|
for (const pool of pools) {
|
|
const normal = Math.min(left, pool.normal);
|
|
left -= normal;
|
|
const elite = Math.min(left, pool.elite);
|
|
left -= elite;
|
|
if (normal + elite) sources[pool.key] = { normal, elite };
|
|
}
|
|
const initial = junctionTransportChoice(view, target.id, sources);
|
|
if (initial.cost > budget) continue;
|
|
const quote = junctionTransportChoice(
|
|
view,
|
|
target.id,
|
|
sources,
|
|
Math.max(0, initial.cost - (me.spice ?? 0)),
|
|
);
|
|
if (quote.action) {
|
|
const key = JSON.stringify(quote.action);
|
|
if (!seen.has(key)) {
|
|
actions.push(quote.action);
|
|
seen.add(key);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return actions;
|
|
}
|