Files
Dune/game/junction-transport-options.ts
T
Quintine 0f8e0b4153
Verify and publish checkpoint container / container (push) Successful in 10m7s
Enforce Atomics shipment ban across transport
2026-10-29 03:03:18 +11:00

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;
}