Implement Homeworld mechanics through Caladan reinforcement checkpoint
Connect typed Homeworld custody, transport, battle, income, revival and Caladan reinforcement flows with private multiplayer recovery and controls. Preserve the optimized test workflow and keep unfinished rules modes gated. Verified with 3682 offline tests, typecheck, lint, production build and 40 HTTP tests; existing saved games remain unchanged.
This commit is contained in:
1 parent
3be49512f7
commit
28ba1618ba
266 files changed
+47680
-16851
No files matched your search
+4
-2
@@ -28,8 +28,10 @@ export function settleAdvisors(g: Game) {
|
||||
|
||||
/** Direct shipments are fighters unless joining existing advisors. */
|
||||
export function arrivalAsAdvisor(
|
||||
g: Game,
|
||||
p: Player,
|
||||
g: Pick<Game, 'advanced'> & {
|
||||
players: readonly (Pick<Player, 'id' | 'faction' | 'advisors'> & ForcePresence)[];
|
||||
},
|
||||
p: Pick<Player, 'id' | 'faction' | 'advisors'> & ForcePresence,
|
||||
to: string,
|
||||
source?: string,
|
||||
accompanying = false,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Game } from './engine';
|
||||
import { location } from './board';
|
||||
import { validAmbassadorResume } from './ambassador-resume';
|
||||
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
|
||||
import { validateHomeworldVictoryReturn } from './homeworld-victory-return';
|
||||
import {
|
||||
validateHomeworldArrival,
|
||||
type HomeworldArrivalFrame,
|
||||
type HomeworldArrivalTag,
|
||||
} from './homeworld-arrival';
|
||||
|
||||
export type AmbassadorPhaseContext = Pick<
|
||||
Game,
|
||||
| 'turn'
|
||||
| 'phase'
|
||||
| 'players'
|
||||
| 'homeworlds'
|
||||
| 'homeworldRevivalReturn'
|
||||
| 'homeworldRevivalProgress'
|
||||
| 'homeworldVictoryReinforcement'
|
||||
| 'lastBattleContext'
|
||||
| 'pendingAmbassador'
|
||||
>;
|
||||
|
||||
/** Extra phases belong only to the recorded arrival and its actual BG suffixes. */
|
||||
export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
|
||||
const entry = g.pendingAmbassador;
|
||||
const candidates: {
|
||||
tag: HomeworldArrivalTag;
|
||||
frame: HomeworldArrivalFrame | undefined;
|
||||
}[] = [
|
||||
{ tag: 'revivalEvent', frame: g.homeworldRevivalReturn },
|
||||
{ tag: 'victoryEvent', frame: g.homeworldVictoryReinforcement },
|
||||
];
|
||||
const matching = candidates.filter(
|
||||
({ tag, frame }) =>
|
||||
entry &&
|
||||
(entry[tag] !== undefined ||
|
||||
(Array.isArray(frame?.ambassadors) &&
|
||||
frame.ambassadors.some((record) => record?.event === entry.event))),
|
||||
);
|
||||
if (!matching.length) return g.phase === 1 || g.phase === 5;
|
||||
if (matching.length !== 1) return false;
|
||||
const { tag, frame } = matching[0];
|
||||
if (
|
||||
!entry ||
|
||||
!frame ||
|
||||
entry[tag] !== frame.event ||
|
||||
frame.stage !== 'arrival' ||
|
||||
frame.turn !== g.turn ||
|
||||
frame.phase !== g.phase ||
|
||||
entry.turn !== g.turn ||
|
||||
entry.phase !== g.phase ||
|
||||
entry.resume !== 'none' ||
|
||||
!validAmbassadorResume(g, entry)
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
if (tag === 'revivalEvent')
|
||||
validateHomeworldRevivalReturn(g, g.homeworldRevivalReturn);
|
||||
else validateHomeworldVictoryReturn(g);
|
||||
validateHomeworldArrival(g, frame, tag);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const record = frame.ambassadors!.at(-1);
|
||||
if (
|
||||
!record ||
|
||||
record.completed ||
|
||||
record.event !== entry.event ||
|
||||
record.entrant !== entry.entrant ||
|
||||
record.destination !== location(entry.territory, entry.sector)
|
||||
)
|
||||
return false;
|
||||
return record.parent === undefined
|
||||
? entry.guildAdvisorOrigin === undefined
|
||||
: entry.guildAdvisorOrigin?.event === record.parent;
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
} from './stone-burner';
|
||||
import { matchingTraitor } from './traitors';
|
||||
import { auditCount } from './choam-auditor';
|
||||
import {
|
||||
quoteHomeworldBattleRules,
|
||||
type HomeworldBattleRules,
|
||||
type HomeworldExplosionLosses,
|
||||
} from './homeworld-battle-rules';
|
||||
|
||||
export class BattleResolutionQuoteError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -63,8 +68,12 @@ export type ResolutionCombatant = ResolutionParticipant & {
|
||||
};
|
||||
export type BattleResolutionInput = {
|
||||
advanced: boolean;
|
||||
/** Preserve physical counter choices in Basic Homeworlds without granting Advanced strength. */
|
||||
typedCasualties?: boolean;
|
||||
turn: number;
|
||||
territory: string;
|
||||
/** Off-planet rules are explicit; a Homeworld is never a stronghold terrain. */
|
||||
homeworld?: HomeworldBattleRules;
|
||||
attacker: ResolutionCombatant;
|
||||
defender: ResolutionCombatant;
|
||||
/** Exact traitorVoters order, with the canceled allied call substituted false. */
|
||||
@@ -107,6 +116,9 @@ export type BattleResolutionQuote = {
|
||||
strongholdIncome: { player: string; amount: number }[];
|
||||
/** Includes noncombatants only for an actual non-traitor explosion. */
|
||||
destroyedArmies: string[];
|
||||
/** Native Lasgun/Shield losses replace destruction, including when the
|
||||
* native is a noncombatant at an invaders' battle. Commit one chosen option. */
|
||||
homeworldExplosion?: HomeworldExplosionLosses;
|
||||
/** Basic ordinary winners lose their dial immediately; Advanced/Ix defer typed losses. */
|
||||
basicWinnerLosses: number | null;
|
||||
casualties: {
|
||||
@@ -153,6 +165,8 @@ function validateCombatant(
|
||||
f.normal + f.elite <= 20 &&
|
||||
(f.eliteStrength === 1 || f.eliteStrength === 2) &&
|
||||
typeof f.freeSupport === 'boolean' &&
|
||||
(f.eliteFreeSupport === undefined ||
|
||||
typeof f.eliteFreeSupport === 'boolean') &&
|
||||
(f.normalFixedHalf === undefined ||
|
||||
typeof f.normalFixedHalf === 'boolean') &&
|
||||
Number.isFinite(p.dial) &&
|
||||
@@ -236,6 +250,35 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
attacker: validateCombatant(a, input.physicalCards),
|
||||
defender: validateCombatant(d, input.physicalCards),
|
||||
};
|
||||
requireQuote(
|
||||
!!input.homeworld === input.territory.startsWith('homeworld:'),
|
||||
'A Homeworld battle must carry its explicit native location rules.',
|
||||
);
|
||||
const homeworld = input.homeworld
|
||||
? quoteHomeworldBattleRules(
|
||||
input.territory,
|
||||
input.participants,
|
||||
input.homeworld,
|
||||
)
|
||||
: null;
|
||||
if (homeworld) {
|
||||
requireQuote(
|
||||
input.advanced || input.homeworld!.card !== 'salusa_secundus',
|
||||
'Salusa Secundus is used only in Advanced Homeworld games.',
|
||||
);
|
||||
requireQuote(
|
||||
!a.stronghold && !d.stronghold &&
|
||||
!input.participants.some((p) => p.noFieldAtTerritory),
|
||||
'Homeworld combat does not inherit stronghold effects or unresolved concealed No-Field combat.',
|
||||
);
|
||||
const native = [a, d].find((side) => side.id === homeworld.native);
|
||||
requireQuote(
|
||||
!native ||
|
||||
(native.forces.normal === input.homeworld!.nativeForces.normal &&
|
||||
native.forces.elite === input.homeworld!.nativeForces.elite),
|
||||
'The native battle army must match its Homeworld custody.',
|
||||
);
|
||||
}
|
||||
const card = (
|
||||
side: ResolutionCombatant,
|
||||
selected: string | null | undefined,
|
||||
@@ -276,9 +319,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
requireQuote(
|
||||
Array.isArray(input.voters) &&
|
||||
new Set(input.voters.map((v) => v.id)).size === input.voters.length &&
|
||||
[a, d].every((s) =>
|
||||
[a, d].filter((s) => !homeworld || s.id === homeworld.native).every((s) =>
|
||||
input.voters.some((v) => v.id === s.id && v.beneficiary === s.id),
|
||||
),
|
||||
) &&
|
||||
(!homeworld || input.voters.every((v) =>
|
||||
v.id === homeworld.native && v.beneficiary === homeworld.native,
|
||||
)),
|
||||
'The battle needs its complete distinct traitor voters.',
|
||||
);
|
||||
const revelations: BattleResolutionQuote['revelations'] = [];
|
||||
@@ -413,11 +459,13 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
scores = {
|
||||
attacker:
|
||||
a.plan.dial +
|
||||
(a.id === homeworld?.native ? homeworld.strength : 0) +
|
||||
(deaths.attacker || effects.stunned
|
||||
? 0
|
||||
: strengths.attacker + (a.plan.kwisatz ? 2 : 0)),
|
||||
defender:
|
||||
d.plan.dial +
|
||||
(d.id === homeworld?.native ? homeworld.strength : 0) +
|
||||
(deaths.defender || effects.stunned
|
||||
? 0
|
||||
: strengths.defender + (d.plan.kwisatz ? 2 : 0)),
|
||||
@@ -440,7 +488,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
}
|
||||
const destroyedArmies =
|
||||
result === 'explosion'
|
||||
? input.participants.map((p) => p.id)
|
||||
? input.participants.filter((p) => p.id !== homeworld?.native).map((p) => p.id)
|
||||
: winner
|
||||
? [winner.id === a.id ? d.id : a.id]
|
||||
: [a.id, d.id];
|
||||
@@ -453,7 +501,12 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
let casualties: BattleResolutionQuote['casualties'] = null;
|
||||
let basicWinnerLosses: number | null = null;
|
||||
if (winner && result === 'normal') {
|
||||
if (input.advanced || winner.faction === 'ixians') {
|
||||
if (
|
||||
input.advanced ||
|
||||
input.typedCasualties ||
|
||||
homeworld ||
|
||||
winner.faction === 'ixians'
|
||||
) {
|
||||
const options = casualtyOptions(
|
||||
winner.forces,
|
||||
winner.plan.dial,
|
||||
@@ -599,6 +652,9 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
bounty,
|
||||
strongholdIncome,
|
||||
destroyedArmies,
|
||||
...(result === 'explosion' && homeworld
|
||||
? { homeworldExplosion: homeworld.explosion }
|
||||
: {}),
|
||||
basicWinnerLosses,
|
||||
casualties,
|
||||
played,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Card } from './cards';
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** These controls consume only the requesting seat's projected opportunity. */
|
||||
export function biddingEndCanAct(g: GameView): boolean {
|
||||
return !!(
|
||||
g.status === 'playing' &&
|
||||
g.phase === 3 &&
|
||||
g.biddingEnd?.canAct &&
|
||||
g.biddingEnd.owners.includes(g.me) &&
|
||||
!g.decision &&
|
||||
!g.response &&
|
||||
!g.truthtrance &&
|
||||
!g.phaseOpening &&
|
||||
!g.automaticContinuationPending
|
||||
);
|
||||
}
|
||||
|
||||
export function biddingEndDiscardChoice(g: GameView, selected: string[]) {
|
||||
const me = g.players.find((p) => p.id === g.me);
|
||||
const end = g.biddingEnd;
|
||||
const cost = selected.length * 2;
|
||||
let blocked: string | null = null;
|
||||
if (!biddingEndCanAct(g)) blocked = 'Wait for the current choice to finish.';
|
||||
else if (end?.kaitain?.owner !== g.me || !end.kaitain.eligible)
|
||||
blocked = 'Paid discards require high-population Kaitain.';
|
||||
else if (!selected.length) blocked = 'Choose at least one card to discard.';
|
||||
else if (
|
||||
new Set(selected).size !== selected.length ||
|
||||
selected.some((id) => !me?.hand?.some((card) => card.id === id))
|
||||
)
|
||||
blocked = 'Choose cards that are still in your hand.';
|
||||
else if (cost > (me?.spice ?? 0))
|
||||
blocked = `You need ${cost} spice to discard these cards.`;
|
||||
return {
|
||||
cost,
|
||||
blocked,
|
||||
action: blocked
|
||||
? null
|
||||
: ({
|
||||
type: 'biddingEnd',
|
||||
event: end!.event,
|
||||
mode: 'discard',
|
||||
cards: selected,
|
||||
} as Action),
|
||||
};
|
||||
}
|
||||
|
||||
/** The same CHOAM policy is used by the ordinary and shared market windows. */
|
||||
export function choamMarketPolicy(
|
||||
g: GameView,
|
||||
value: (card: Card) => number,
|
||||
preserve?: unknown,
|
||||
): Action {
|
||||
const market = g.choamMarket!;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const sales = market.sales?.filter((sale) => sale.card !== preserve);
|
||||
const sale = sales?.find((candidate) => candidate.price === 3) ?? sales?.[0];
|
||||
if (sale)
|
||||
return {
|
||||
type: 'decision',
|
||||
mode: 'sell',
|
||||
card: sale.card,
|
||||
witness: sale.witness,
|
||||
};
|
||||
const offerable = me.hand?.filter((card) => card.id !== preserve) ?? [];
|
||||
if (market.canTrade && !market.tradeAttempted && offerable.length) {
|
||||
const offered = [...offerable].sort((a, b) => value(a) - value(b))[0];
|
||||
return { type: 'decision', mode: 'trade', card: offered.id };
|
||||
}
|
||||
return { type: 'decision', done: true };
|
||||
}
|
||||
|
||||
export function biddingEndActions(
|
||||
g: GameView,
|
||||
level: number,
|
||||
value: (card: Card) => number,
|
||||
preserve?: unknown,
|
||||
): Action[] {
|
||||
if (!biddingEndCanAct(g)) return [];
|
||||
const end = g.biddingEnd!;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
if (!(me.bot ?? me.autopilot)) return [];
|
||||
if (end.kaitain?.owner === g.me && end.kaitain.eligible) {
|
||||
// Preserve a shipment/revival budget. Emptying a useful hand merely to pay
|
||||
// for new cards next turn is not beneficial, including for the Easy bot.
|
||||
const budget = Math.max(0, (me.spice ?? 0) - [2, 3, 4, 4][level]);
|
||||
const unwanted = (me.hand ?? [])
|
||||
.filter((card) => card.kind === 'worthless' && card.id !== preserve)
|
||||
.slice(0, Math.floor(budget / 2));
|
||||
if (unwanted.length) {
|
||||
const choice = biddingEndDiscardChoice(
|
||||
g,
|
||||
unwanted.map((card) => card.id),
|
||||
);
|
||||
if (choice.action) return [choice.action];
|
||||
}
|
||||
}
|
||||
if (g.choamMarket?.owner === g.me) {
|
||||
const action = choamMarketPolicy(g, value, preserve);
|
||||
if (!action.done)
|
||||
return [{ ...action, type: 'biddingEnd', event: end.event }];
|
||||
}
|
||||
return end.ready.includes(g.me)
|
||||
? []
|
||||
: [{ type: 'biddingEnd', event: end.event, mode: 'ready' }];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Game } from './engine';
|
||||
|
||||
export type BiddingEnd = {
|
||||
event: string;
|
||||
turn: number;
|
||||
owners: string[];
|
||||
ready: string[];
|
||||
};
|
||||
|
||||
export function biddingEndQuiet(g: Game): boolean {
|
||||
return !!g.biddingEnd && !g.decision && !g.response && !g.truthtrance &&
|
||||
!g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingNullentropy &&
|
||||
!g.pendingKarama && !g.pendingRicheseGift && !g.pendingExchange &&
|
||||
!g.pendingRichesePurchaseIncome && !g.pendingChoamMarketGhola &&
|
||||
!g.choamMarket?.sale && !g.choamMarket?.trade;
|
||||
}
|
||||
|
||||
/** Bidding hand counts are public. Never inspect private card identities or
|
||||
* spice to decide whether to skip an owner's opportunity. */
|
||||
export function biddingEndPubliclyEmpty(g: Game, id: string): boolean {
|
||||
const owner = g.players.find((p) => p.id === id)!;
|
||||
return owner.hand.length === 0 && g.players.every((p) => p.id === id ||
|
||||
(p.hand.length < 2 && !(p.faction === 'richese' && p.ally === id &&
|
||||
owner.ally === p.id && p.hand.length > 0)));
|
||||
}
|
||||
|
||||
export function biddingEndError(g: Game): string | null {
|
||||
const end = g.biddingEnd;
|
||||
if (!end) return null;
|
||||
const emperor = g.players.find((p) => p.faction === 'emperor');
|
||||
const choam = g.players.find((p) => p.faction === 'choam');
|
||||
const owners = [emperor?.id, choam?.id].filter((id): id is string => !!id);
|
||||
if (g.status !== 'playing' || g.phase !== 3 || end.turn !== g.turn ||
|
||||
!g.homeworlds?.custody || !emperor ||
|
||||
typeof end.event !== 'string' || !end.event ||
|
||||
JSON.stringify(end.owners) !== JSON.stringify(owners) ||
|
||||
!Array.isArray(end.ready) || new Set(end.ready).size !== end.ready.length ||
|
||||
end.ready.some((id) => !owners.includes(id)) ||
|
||||
!!g.auction || !!g.richeseAuction || !!g.currentAuctionSale ||
|
||||
(choam ? g.choamMarket?.owner !== choam.id || g.choamMarket.resume !== 'phase'
|
||||
: !!g.choamMarket))
|
||||
return 'The saved end-of-Bidding opportunity no longer matches its owners or completed auctions.';
|
||||
return null;
|
||||
}
|
||||
@@ -147,6 +147,8 @@ export type CollectionQuote = {
|
||||
player: string;
|
||||
strongholds: number;
|
||||
collected: number;
|
||||
/** Actual desert deposits, excluding bank income and shared escrow. */
|
||||
desert: number;
|
||||
balance: number;
|
||||
}[];
|
||||
/** Removed from the map, but not credited until the allies resolve allocation. */
|
||||
@@ -283,6 +285,7 @@ export function quoteSpiceCollection(
|
||||
if (ecazCollectionCanceled && collectionBonus?.owner === id)
|
||||
strongholds -= collectionBonus.amount;
|
||||
let collected = 0;
|
||||
let desert = 0;
|
||||
for (const [key, capacity] of capacities.get(id)!) {
|
||||
const t = splitLocation(key).territory;
|
||||
if (
|
||||
@@ -314,6 +317,8 @@ export function quoteSpiceCollection(
|
||||
}
|
||||
const amount = Math.min(spice[key] ?? 0, capacity);
|
||||
collected += amount;
|
||||
if (sites.some((site) => site.id === t && site.type === 'sand'))
|
||||
desert += amount;
|
||||
spice[key] = (spice[key] ?? 0) - amount;
|
||||
}
|
||||
const balance = original.spice + strongholds + collected;
|
||||
@@ -321,7 +326,7 @@ export function quoteSpiceCollection(
|
||||
whole(collected) && whole(balance),
|
||||
'Collection would overflow the spice balance.',
|
||||
);
|
||||
receipts.push({ player: id, strongholds, collected, balance });
|
||||
receipts.push({ player: id, strongholds, collected, desert, balance });
|
||||
}
|
||||
return { released, spice, receipts, shared, collectionBonus };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
export function botBattleChoices(g: GameView): Action[] {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
if (g.active !== me.id) return [];
|
||||
if (Array.isArray(g.battleChoices))
|
||||
return g.battleChoices
|
||||
.filter((battle) => battle.attacker === me.id)
|
||||
.map((battle) => ({
|
||||
type: 'chooseBattle',
|
||||
territory: battle.territory,
|
||||
target: battle.defender,
|
||||
}));
|
||||
// Legacy component fixtures may predate the authoritative combat frontier.
|
||||
const ownLocations = Object.keys(presenceByLocation(me));
|
||||
return gameTerritories(g)
|
||||
.filter((t) => t.type !== 'polar' && fighterCount(me, t.id) > 0)
|
||||
|
||||
+234
-76
@@ -1,3 +1,16 @@
|
||||
import { biddingEndActions, choamMarketPolicy } from './bidding-end-options';
|
||||
import { homeworldRevivalActionBlock, homeworldRevivalDeploymentActions } from './homeworld-revival-deployment-options';
|
||||
import { caladanReinforcementActions } from './caladan-reinforcement-options';
|
||||
import { botHomeworldShipmentPaymentAllowed } from './homeworld-payment-options';
|
||||
import { guildHomeworldShipmentActions } from './guild-homeworld-shipment-options';
|
||||
import { choamSaleGholaTiming } from './choam-market-ghola';
|
||||
import { junctionTransportActions } from './junction-transport-options';
|
||||
import { currentJunctionOffer, junctionSponsor } from './junction-offer';
|
||||
import { homeworldShipmentActions } from './homeworld-shipment-options';
|
||||
import {
|
||||
emperorHomeworldMoveActions,
|
||||
withNativeShipmentSources,
|
||||
} from './homeworld-options';
|
||||
import { matchesPlanClaim, respectsBattlePromises } from './battle-promises';
|
||||
import { ecazOccupancyRelation } from './ecaz-occupy';
|
||||
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
|
||||
@@ -241,16 +254,10 @@ function guildAmbassadorShipments(g: GameView): Action[] {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const descriptor = entry.shipment;
|
||||
const level = rank(g);
|
||||
const amount = Math.min(descriptor.maximum, me.reserves);
|
||||
const available = Math.min(descriptor.maximum, me.reserves);
|
||||
const destinations = descriptor.destinations.filter((d) => !d.blocked);
|
||||
if (amount < 1 || !destinations.length)
|
||||
if (available < 1 || !destinations.length)
|
||||
return [{ type: 'decision', event: entry.event, amount: 0 }];
|
||||
const minimum = Math.max(
|
||||
0,
|
||||
amount - (me.reserves - descriptor.eliteReserves),
|
||||
);
|
||||
const elite =
|
||||
level === 0 ? minimum : Math.min(amount, descriptor.eliteReserves);
|
||||
const score = (d: (typeof destinations)[number]) => {
|
||||
const key = location(d.territory, d.sector);
|
||||
if (level === 0) return variation(g, key);
|
||||
@@ -267,6 +274,9 @@ function guildAmbassadorShipments(g: GameView): Action[] {
|
||||
};
|
||||
destinations.sort((a, b) => score(b) - score(a));
|
||||
const destination = destinations[0];
|
||||
const amount = Math.min(available, destination.maximum ?? available);
|
||||
const minimum = Math.max(0, amount - (me.reserves - descriptor.eliteReserves));
|
||||
const elite = level === 0 ? minimum : Math.min(amount, descriptor.eliteReserves);
|
||||
return [
|
||||
{
|
||||
type: 'decision',
|
||||
@@ -302,7 +312,10 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
|
||||
territory(source.territory).type === 'stronghold' &&
|
||||
physical > 1 &&
|
||||
presenceAt(me, source.territory) === physical;
|
||||
let remaining = physical - (keepsSource ? 1 : 0);
|
||||
let remaining = Math.min(
|
||||
physical - (keepsSource ? 1 : 0),
|
||||
destination.maximum ?? physical,
|
||||
);
|
||||
const forces: Record<string, number> = {};
|
||||
const eliteForces: Record<string, number> = {};
|
||||
for (const s of sectors) {
|
||||
@@ -368,6 +381,7 @@ function ornithopterMoves(g: GameView): Action[] {
|
||||
: null;
|
||||
const permittedMarker =
|
||||
marker &&
|
||||
!g.homeworldMobility?.noFieldMovementBlocked &&
|
||||
(!cohort ||
|
||||
(cohort.noField?.tokenId === marker.tokenId &&
|
||||
cohort.noField.event === noField?.event &&
|
||||
@@ -533,6 +547,7 @@ function plans(g: GameView): Action[] {
|
||||
normalFixedHalf: me.faction === 'ixians',
|
||||
elite,
|
||||
eliteStrength:
|
||||
(!g.advanced && me.faction !== 'ixians') ||
|
||||
(b.eliteBlocked.includes(me.id) &&
|
||||
!(g.advanced && me.faction === 'ixians')) ||
|
||||
(me.faction === 'emperor' && other.faction === 'fremen')
|
||||
@@ -593,7 +608,21 @@ function plans(g: GameView): Action[] {
|
||||
(_, i) => i / 2,
|
||||
).filter((dial) => (supporting(dial)?.support ?? 0) > 0)
|
||||
: [];
|
||||
const enemies = presenceAt(other, b.territory);
|
||||
const freeEliteDials = forces.eliteFreeSupport
|
||||
? Array.from(
|
||||
{ length: Math.floor(ownStrength * 2) + 1 },
|
||||
(_, i) => i / 2,
|
||||
).filter((dial) => casualtyOptions(forces, dial, 0).length > 0)
|
||||
: [];
|
||||
const enemies = b.opponentForces
|
||||
? b.opponentForces.normal + b.opponentForces.elite
|
||||
: presenceAt(other, b.territory);
|
||||
const ownNativeBonus = b.native === me.id ? b.nativeBattleStrength : 0;
|
||||
const enemyNativeBonus = b.native === other.id ? b.nativeBattleStrength : 0;
|
||||
const enemyCapacity = b.opponentForces
|
||||
? b.opponentForces.normal * (b.opponentForces.normalFixedHalf ? 0.5 : 1) +
|
||||
b.opponentForces.elite * b.opponentForces.eliteStrength
|
||||
: enemies;
|
||||
const insight = b.insight;
|
||||
const revealedCard =
|
||||
insight && ['weapon', 'defense'].includes(insight.field)
|
||||
@@ -658,7 +687,7 @@ function plans(g: GameView): Action[] {
|
||||
? inspected.plan.dial
|
||||
: insight?.field === 'dial' && b.prescience?.player === me.id
|
||||
? Number(insight.value)
|
||||
: Math.ceil(enemies * [0.25, 0.4, 0.6, 0.75][level]);
|
||||
: Math.ceil(enemyCapacity * [0.25, 0.4, 0.6, 0.75][level]);
|
||||
const options: { action: Action; score: number }[] = [];
|
||||
for (const leader of leaders)
|
||||
for (const weapon of weapons)
|
||||
@@ -722,7 +751,7 @@ function plans(g: GameView): Action[] {
|
||||
...g.players.filter(
|
||||
(p) => p.faction === 'harkonnen' && p.ally === other.id,
|
||||
),
|
||||
];
|
||||
].filter((holder) => !b.native || holder.id === b.native);
|
||||
const exposedLeader =
|
||||
!kwisatz &&
|
||||
traitorHolders.some((p) =>
|
||||
@@ -759,12 +788,15 @@ function plans(g: GameView): Action[] {
|
||||
enemySurvivingStrength +
|
||||
expectedDial -
|
||||
ownSurvivingStrength +
|
||||
enemyNativeBonus -
|
||||
ownNativeBonus +
|
||||
(b.tieWinner === me.id ? 0 : typedForces ? 0.5 : 1),
|
||||
),
|
||||
);
|
||||
for (const dial of new Set([
|
||||
ideal,
|
||||
...bankSupportedDials,
|
||||
...freeEliteDials,
|
||||
0,
|
||||
...(typedForces ? [0.5] : []),
|
||||
Math.max(0, ownStrength - 1),
|
||||
@@ -1162,6 +1194,7 @@ function policyActions(g: GameView): Action[] {
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
g.mobileStronghold?.location &&
|
||||
!g.homeworldMobility?.mobileStrongholdBlocked &&
|
||||
(me.forces[MOBILE_LOCATION] ?? 0) > 0
|
||||
) {
|
||||
const capacity = (me.forces[MOBILE_LOCATION] ?? 0) * 2;
|
||||
@@ -1247,7 +1280,10 @@ function policyActions(g: GameView): Action[] {
|
||||
leader: dead.id,
|
||||
},
|
||||
];
|
||||
const elite = Math.min(me.elites?.tanks ?? 0, eliteRevivalRemaining(me));
|
||||
const elite = Math.min(
|
||||
me.elites?.tanks ?? 0,
|
||||
eliteRevivalRemaining(me, g.advanced),
|
||||
);
|
||||
const amount = Math.min(3, me.tanks - (me.elites?.tanks ?? 0) + elite);
|
||||
if (amount === 3 || (level === 0 && amount > 0))
|
||||
return [
|
||||
@@ -1299,6 +1335,10 @@ function policyActions(g: GameView): Action[] {
|
||||
? []
|
||||
: [{ type: 'passResponse' }];
|
||||
}
|
||||
if (g.biddingEnd && !g.decision)
|
||||
return biddingEndActions(
|
||||
g, level, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
|
||||
);
|
||||
if (g.decision) {
|
||||
if (g.decision.player !== me.id) return [];
|
||||
const d = g.decision;
|
||||
@@ -1550,17 +1590,25 @@ function policyActions(g: GameView): Action[] {
|
||||
(me.noField.deployed.location.sector === 0 ||
|
||||
me.noField.deployed.location.sector !== g.storm)
|
||||
);
|
||||
if (entry.stage === 'allianceReply') return [{ ...action, accept: true }];
|
||||
if (entry.stage === 'offer') {
|
||||
if (entry.effect === 'ecaz')
|
||||
return [
|
||||
entry.dukeAcquisition && !entry.dukeAcquisition.blocked
|
||||
entry.allianceOffer && !entry.allianceOffer.blocked
|
||||
? {
|
||||
...action,
|
||||
trigger: true,
|
||||
beneficiary: entry.owner,
|
||||
choice: 'duke',
|
||||
choice: 'alliance',
|
||||
}
|
||||
: { ...action, decline: true },
|
||||
: entry.dukeAcquisition && !entry.dukeAcquisition.blocked
|
||||
? {
|
||||
...action,
|
||||
trigger: true,
|
||||
beneficiary: entry.owner,
|
||||
choice: 'duke',
|
||||
}
|
||||
: { ...action, decline: true },
|
||||
];
|
||||
const available = entry.beneficiaries.filter(
|
||||
(option) =>
|
||||
@@ -1719,7 +1767,12 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (d.kind === 'choamBattleFunding') {
|
||||
const ally = g.players.find((p) => p.id === me.ally)!;
|
||||
const forces = countAt(ally, g.battle!.territory);
|
||||
const pool = g.combatLocations?.find(
|
||||
(place) => place.id === g.battle!.territory,
|
||||
)?.forces?.[ally.id];
|
||||
const forces = pool
|
||||
? pool.normal + pool.elite
|
||||
: countAt(ally, g.battle!.territory);
|
||||
const total = (me.spice ?? 0) + g.aid.pledged;
|
||||
const amount = Math.max(
|
||||
g.aid.pledged,
|
||||
@@ -1727,29 +1780,10 @@ function policyActions(g: GameView): Action[] {
|
||||
);
|
||||
return [{ type: 'decision', amount }];
|
||||
}
|
||||
if (d.kind === 'choamMarket') {
|
||||
const market = g.choamMarket!;
|
||||
const preserve = gamontAction(g)?.card;
|
||||
const sales = market.sales?.filter((s) => s.card !== preserve);
|
||||
const sale = sales?.find((s) => s.price === 3) ?? sales?.[0];
|
||||
if (sale)
|
||||
return [
|
||||
{
|
||||
type: 'decision',
|
||||
mode: 'sell',
|
||||
card: sale.card,
|
||||
witness: sale.witness,
|
||||
},
|
||||
];
|
||||
const offerable = me.hand?.filter((c) => c.id !== preserve) ?? [];
|
||||
if (market.canTrade && !market.tradeAttempted && offerable.length) {
|
||||
const offered = [...offerable].sort(
|
||||
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
|
||||
)[0];
|
||||
return [{ type: 'decision', mode: 'trade', card: offered.id }];
|
||||
}
|
||||
return [{ type: 'decision', done: true }];
|
||||
}
|
||||
if (d.kind === 'choamMarket')
|
||||
return [choamMarketPolicy(
|
||||
g, (card) => technologyCardValue(g, card), gamontAction(g)?.card,
|
||||
)];
|
||||
if (d.kind === 'choamTradeReply') {
|
||||
const returned = [...(me.hand ?? [])].sort(
|
||||
(a, b) => technologyCardValue(g, a) - technologyCardValue(g, b),
|
||||
@@ -1807,6 +1841,8 @@ function policyActions(g: GameView): Action[] {
|
||||
},
|
||||
];
|
||||
if (d.kind === 'mobileStronghold') {
|
||||
if (!d.placement && g.homeworldMobility?.mobileStrongholdBlocked)
|
||||
return [{type: 'decision', decline: true}];
|
||||
if (d.placement)
|
||||
return TERRITORIES.filter((t) => t.type !== 'stronghold')
|
||||
.flatMap((t) =>
|
||||
@@ -1839,9 +1875,16 @@ function policyActions(g: GameView): Action[] {
|
||||
];
|
||||
}
|
||||
if (d.kind === 'ixSubstitution') {
|
||||
const sourceEntries = Object.entries(me.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.map(([key, n]) => [key, n - (me.elites?.forces[key] ?? 0)] as const);
|
||||
const world = g.combatLocations?.find(
|
||||
(place) => place.id === d.territory && place.kind === 'homeworld',
|
||||
);
|
||||
const sourceEntries = world
|
||||
? [[world.id, world.forces?.[me.id]?.normal ?? 0] as const]
|
||||
: Object.entries(me.forces)
|
||||
.filter(([key]) => splitLocation(key).territory === d.territory)
|
||||
.map(
|
||||
([key, n]) => [key, n - (me.elites?.forces[key] ?? 0)] as const,
|
||||
);
|
||||
const max = Math.min(
|
||||
Object.values(d.losses).reduce((a, b) => a + b, 0),
|
||||
sourceEntries.reduce((sum, [, n]) => sum + n, 0),
|
||||
@@ -1877,7 +1920,7 @@ function policyActions(g: GameView): Action[] {
|
||||
target.id !== me.ally &&
|
||||
(target.tanks >= [12, 8, 5, 3][level] ||
|
||||
(level > 0 && d.revival === 'leader' && noLeader));
|
||||
if (specialCard && shouldStop)
|
||||
if (specialCard && shouldStop && !g.revival.specialKaramaBlock)
|
||||
return [
|
||||
{
|
||||
type: 'card',
|
||||
@@ -1890,16 +1933,25 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (d.kind === 'faceDance') {
|
||||
if (
|
||||
d.blocked ||
|
||||
!me.faceDancers?.some((c) => !c.revealed && c.leader === d.identity) ||
|
||||
me.ally === d.winner
|
||||
)
|
||||
return [{ type: 'decision', reveal: false }];
|
||||
const winner = g.players.find((p) => p.id === d.winner)!;
|
||||
let needed = countAt(winner, d.territory);
|
||||
const sources: Record<string, number> = {
|
||||
reserves: Math.min(me.reserves, needed),
|
||||
};
|
||||
needed -= sources.reserves;
|
||||
const world = g.combatLocations?.find(
|
||||
(place) => place.id === d.territory && place.kind === 'homeworld',
|
||||
);
|
||||
const pool = world?.forces?.[winner.id];
|
||||
let needed = world
|
||||
? (pool?.normal ?? 0) + (pool?.elite ?? 0)
|
||||
: countAt(winner, d.territory);
|
||||
const sources: Record<string, number> = world
|
||||
? {}
|
||||
: {
|
||||
reserves: Math.min(me.reserves, needed),
|
||||
};
|
||||
needed -= sources.reserves ?? 0;
|
||||
for (const [key, amount] of Object.entries(me.forces).sort(
|
||||
([a], [b]) =>
|
||||
(territory(splitLocation(a).territory).type === 'stronghold'
|
||||
@@ -1925,12 +1977,63 @@ function policyActions(g: GameView): Action[] {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
sources,
|
||||
sector:
|
||||
territory(d.territory).sectors.find((s) => s !== g.storm) ??
|
||||
territory(d.territory).sectors[0],
|
||||
...(world
|
||||
? {}
|
||||
: {
|
||||
sector:
|
||||
territory(d.territory).sectors.find((s) => s !== g.storm) ??
|
||||
territory(d.territory).sectors[0],
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (d.kind === 'homeworldDefense') {
|
||||
const b = g.battle!;
|
||||
const own = b.plans[me.id];
|
||||
const opponentId = b.attacker === me.id ? b.defender : b.attacker;
|
||||
const opponent = b.plans[opponentId];
|
||||
const snooper = me.hand?.find(
|
||||
(card) =>
|
||||
richeseCardDefinition(card)?.card.effect === 'portableSnooper',
|
||||
);
|
||||
const card = (id: string | null) =>
|
||||
b.cards.find((held) => held.id === id);
|
||||
const dead = (late: boolean) =>
|
||||
strongholdBattleEffects(
|
||||
card(own.weapon),
|
||||
late ? snooper : card(b.lateDefense[me.id] ?? own.defense),
|
||||
card(opponent.weapon),
|
||||
card(b.lateDefense[opponentId] ?? opponent.defense),
|
||||
b.poisonTooth[me.id] ?? true,
|
||||
b.poisonTooth[opponentId] ?? true,
|
||||
b.strongholdEffects[me.id],
|
||||
b.strongholdEffects[opponentId],
|
||||
).attackerDead;
|
||||
const hasLeader =
|
||||
g.allLeaders.some((leader) => leader.id === own.leader) ||
|
||||
(!!own.kwisatz && card(own.leader)?.kind === 'hero');
|
||||
return [
|
||||
{
|
||||
type: 'decision',
|
||||
event: d.event,
|
||||
use: !!snooper && hasLeader && dead(false) && !dead(true),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (d.kind === 'homeworldExplosion') {
|
||||
const choices = d.options
|
||||
.map((loss, choice) => ({
|
||||
choice,
|
||||
cost:
|
||||
level === 0
|
||||
? variation(g, `homeworld-explosion-${choice}`)
|
||||
: loss.normal + loss.elite * 2.5,
|
||||
}))
|
||||
.sort((a, b) => a.cost - b.cost);
|
||||
return choices.length
|
||||
? [{ type: 'decision', event: d.event, choice: choices[0].choice }]
|
||||
: [];
|
||||
}
|
||||
if (d.kind === 'techToken') {
|
||||
const token = [...d.choices].sort((a, b) =>
|
||||
level === 0
|
||||
@@ -1949,6 +2052,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const ownDisc = g.allLeaders.find((l) => l.id === own.leader);
|
||||
const otherDisc = g.allLeaders.find((l) => l.id === other.leader);
|
||||
const alreadyWinsByTraitor =
|
||||
b.traitorVoters.includes(me.id) &&
|
||||
!other.kwisatz &&
|
||||
!!matchingTraitor(me.traitors ?? [], other.leader, card(other.leader));
|
||||
const side = me.id === b.attacker ? 'attacker' : 'defender';
|
||||
@@ -2041,8 +2145,14 @@ function policyActions(g: GameView): Action[] {
|
||||
b.plans[id === b.attacker ? b.defender : b.attacker].leader,
|
||||
),
|
||||
) + (b.plans[id].kwisatz ? 2 : 0);
|
||||
const av = a.dial + strength(b.attacker, effects.attackerDead),
|
||||
dv = opponent.dial + strength(b.defender, effects.defenderDead);
|
||||
const av =
|
||||
a.dial +
|
||||
strength(b.attacker, effects.attackerDead) +
|
||||
(b.native === b.attacker ? b.nativeBattleStrength : 0),
|
||||
dv =
|
||||
opponent.dial +
|
||||
strength(b.defender, effects.defenderDead) +
|
||||
(b.native === b.defender ? b.nativeBattleStrength : 0);
|
||||
const winsA = av > dv || (av === dv && b.tieWinner === b.attacker);
|
||||
const wins = winsA === (me.id === b.attacker);
|
||||
const ownDead =
|
||||
@@ -2088,6 +2198,14 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
// A legacy inspection gate is cleared by authoritative normalization.
|
||||
if (d.kind === 'fullPlanRead') return [];
|
||||
if (d.kind === 'homeworldShipmentGuild') {
|
||||
const world = g.homeworlds?.worlds?.find((home) => home.id === d.destination);
|
||||
const enemy = d.shipper !== me.id && d.shipper !== me.ally;
|
||||
const threatened = world?.native === me.id || !!world?.forces[me.id];
|
||||
if (specialCard && enemy && (level === 0 || threatened || (level >= 2 && d.amount >= 5)))
|
||||
return [{ type: 'card', mode: 'special', card: specialCard.id }];
|
||||
return [{ type: 'decision', event: d.event, allow: true }];
|
||||
}
|
||||
if (d.kind === 'guildShipment') {
|
||||
const enemy = d.shipper !== me.id && d.shipper !== me.ally;
|
||||
const threatened = fighterCount(me, d.territory) > 0;
|
||||
@@ -2196,16 +2314,18 @@ function policyActions(g: GameView): Action[] {
|
||||
accompany: choice.accompany,
|
||||
territory: choice.territory,
|
||||
sector: choice.sector,
|
||||
amount: choice.amount,
|
||||
})),
|
||||
{ type: 'decision', accept: false },
|
||||
];
|
||||
}
|
||||
if (d.kind === 'advisor')
|
||||
return [
|
||||
...(g.advanced && d.destination
|
||||
...(g.advanced && d.destination && (g.homeworldMobility?.advisorAccompanyMaximum ?? 1) > 0
|
||||
? [{ type: 'decision', accept: true, accompany: true }]
|
||||
: []),
|
||||
{ type: 'decision', accept: true },
|
||||
...((g.homeworldMobility?.advisorSinkMaximum ?? 1) > 0
|
||||
? [{ type: 'decision', accept: true, amount: level === 0 ? 1 : (g.homeworldMobility?.advisorSinkMaximum ?? 1) }] : []),
|
||||
{ type: 'decision', accept: false },
|
||||
];
|
||||
if (d.kind === 'wormProtection')
|
||||
@@ -2254,6 +2374,10 @@ function policyActions(g: GameView): Action[] {
|
||||
{ type: 'decision', karama: !!karama },
|
||||
];
|
||||
}
|
||||
if (d.kind === 'homeworldRevivalDeployment')
|
||||
return homeworldRevivalDeploymentActions(g);
|
||||
if (d.kind === 'caladanReinforcement')
|
||||
return caladanReinforcementActions(g);
|
||||
if (d.kind === 'battleCards')
|
||||
return [
|
||||
{
|
||||
@@ -2441,7 +2565,7 @@ function policyActions(g: GameView): Action[] {
|
||||
}
|
||||
if (g.nexus && !g.spiceWindow && !me.ally) {
|
||||
const offer = Object.entries(g.allianceOffers).find(
|
||||
([id, target]) => id !== me.id && target === me.id,
|
||||
([id, target]) => id !== me.id && target === me.id && !g.homeworldAllianceBlocks?.[id],
|
||||
);
|
||||
if (offer && level > 0) return [{ type: 'alliance', target: offer[0] }];
|
||||
}
|
||||
@@ -2605,7 +2729,10 @@ function policyActions(g: GameView): Action[] {
|
||||
: Math.min(
|
||||
ally.tanks -
|
||||
(ally.elites?.tanks ?? 0) +
|
||||
Math.min(ally.elites?.tanks ?? 0, eliteRevivalRemaining(ally)),
|
||||
Math.min(
|
||||
ally.elites?.tanks ?? 0,
|
||||
eliteRevivalRemaining(ally, g.advanced),
|
||||
),
|
||||
3 - (g.emperorExtra[ally.id] ?? 0),
|
||||
Math.floor(Math.max(0, (me.spice ?? 0) - 3) / 2),
|
||||
);
|
||||
@@ -2615,7 +2742,7 @@ function policyActions(g: GameView): Action[] {
|
||||
amount - (ally.tanks - (ally.elites?.tanks ?? 0)),
|
||||
);
|
||||
for (const elite of new Set([
|
||||
Math.min(amount, eliteRevivalRemaining(ally)),
|
||||
Math.min(amount, eliteRevivalRemaining(ally, g.advanced)),
|
||||
minimumElite,
|
||||
]))
|
||||
if (paidForceRevivalCost(ally, amount, elite) <= (me.spice ?? 0) - 3)
|
||||
@@ -2625,7 +2752,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const freeRemaining = g.revival.freeRemaining;
|
||||
const eliteAllowance = Math.min(
|
||||
me.elites?.tanks ?? 0,
|
||||
eliteRevivalRemaining(me),
|
||||
eliteRevivalRemaining(me, g.advanced),
|
||||
);
|
||||
const maximum = Math.min(
|
||||
me.tanks - (me.elites?.tanks ?? 0) + eliteAllowance,
|
||||
@@ -2685,7 +2812,7 @@ function policyActions(g: GameView): Action[] {
|
||||
)
|
||||
return cardMoves;
|
||||
if (g.ornithopter?.active && me.shipped) return [{ type: 'endMovement' }];
|
||||
const actions: Action[] = [];
|
||||
const actions: Action[] = [...guildHomeworldShipmentActions(g, level), ...homeworldShipmentActions(g, level)];
|
||||
const targets = destinations(g);
|
||||
const halfRate =
|
||||
me.faction === 'guild' ||
|
||||
@@ -2741,7 +2868,7 @@ function policyActions(g: GameView): Action[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (marker && noField && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
|
||||
if (marker && noField && !g.homeworldMobility?.noFieldMovementBlocked && (me.moved ?? 0) < (me.movesAllowed ?? 1)) {
|
||||
const from = location(marker.location.territory, marker.location.sector);
|
||||
const range =
|
||||
fighterCount(me, 'arrakeen') || fighterCount(me, 'carthag') ? 3 : 1;
|
||||
@@ -2899,6 +3026,8 @@ function policyActions(g: GameView): Action[] {
|
||||
});
|
||||
}
|
||||
if (reveal && !actions.length) actions.push(reveal);
|
||||
if (me.shipped || !actions.some((action) => action.type === 'ship' || action.type === 'homeworldShip'))
|
||||
actions.unshift(...emperorHomeworldMoveActions(g));
|
||||
actions.push({ type: 'endMovement' });
|
||||
return g.ornithopter?.active
|
||||
? actions.filter((action) => action.type !== 'move')
|
||||
@@ -2968,16 +3097,14 @@ function policyActions(g: GameView): Action[] {
|
||||
}));
|
||||
}
|
||||
if (b.revealed) {
|
||||
if (
|
||||
!b.traitorVoters.includes(me.id) ||
|
||||
b.traitorSubmitted.includes(me.id)
|
||||
)
|
||||
return [];
|
||||
const ownsTraitorDecision =
|
||||
b.traitorVoters.includes(me.id) && !b.traitorSubmitted.includes(me.id);
|
||||
const beneficiary = [b.attacker, b.defender].includes(me.id)
|
||||
? me.id
|
||||
: me.ally;
|
||||
const enemy = b.attacker === beneficiary ? b.defender : b.attacker;
|
||||
const call =
|
||||
ownsTraitorDecision &&
|
||||
!b.plans[enemy].kwisatz &&
|
||||
!!matchingTraitor(
|
||||
me.traitors ?? [],
|
||||
@@ -3029,6 +3156,7 @@ function policyActions(g: GameView): Action[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!ownsTraitorDecision) return [];
|
||||
return [
|
||||
{
|
||||
type: 'traitorCall',
|
||||
@@ -3083,12 +3211,13 @@ function policyActions(g: GameView): Action[] {
|
||||
function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const options = g.ghola;
|
||||
const duringSale = choamSaleGholaTiming(g);
|
||||
if (
|
||||
!(me.bot ?? me.autopilot) ||
|
||||
g.status !== 'playing' ||
|
||||
!options?.available ||
|
||||
g.battle ||
|
||||
g.response ||
|
||||
(g.response && !duringSale) ||
|
||||
g.decision ||
|
||||
g.truthtrance ||
|
||||
g.phaseOpening ||
|
||||
@@ -3096,14 +3225,17 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
g.auction ||
|
||||
g.richeseAuction ||
|
||||
g.phase <= 1 ||
|
||||
g.ready.includes(me.id) ||
|
||||
([5, 6].includes(g.phase) && g.active !== me.id) ||
|
||||
(g.ready.includes(me.id) && !duringSale) ||
|
||||
([5, 6].includes(g.phase) && g.active !== me.id && !duringSale) ||
|
||||
g.shipmentCompletion?.actions.length ||
|
||||
(ordinary[0] &&
|
||||
(!duringSale && ordinary[0] &&
|
||||
![
|
||||
'ready',
|
||||
'ship',
|
||||
'guildShip',
|
||||
'homeworldShip',
|
||||
'guildHomeworldShip',
|
||||
'junctionShip',
|
||||
'move',
|
||||
'endMovement',
|
||||
'chooseBattle',
|
||||
@@ -3156,10 +3288,13 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
/** Obligations apply across policy branches, including choosing to move first. */
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.automaticContinuationPending) return [];
|
||||
const actions = policyActions(g);
|
||||
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
|
||||
const sourced = withNativeShipmentSources(g, action);
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : [];
|
||||
});
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const ghola = standaloneGholaAction(g, actions);
|
||||
if (ghola) return [ghola];
|
||||
if (ghola && !homeworldRevivalActionBlock(g, ghola)) return [ghola];
|
||||
if (
|
||||
g.phase !== 5 ||
|
||||
g.active !== me.id ||
|
||||
@@ -3177,12 +3312,30 @@ export function botActions(g: GameView): Action[] {
|
||||
);
|
||||
if (!promises.length) return actions;
|
||||
const filtered = actions.filter((action) => {
|
||||
if (!['ship', 'guildShip', 'move', 'endMovement'].includes(action.type))
|
||||
if (
|
||||
![
|
||||
'ship',
|
||||
'guildShip',
|
||||
'homeworldShip',
|
||||
'guildHomeworldShip',
|
||||
'junctionShip',
|
||||
'move',
|
||||
'emperorHomeworldMove',
|
||||
'endMovement',
|
||||
].includes(action.type)
|
||||
)
|
||||
return true;
|
||||
const junctionNative = action.type === 'junctionShip' &&
|
||||
typeof action.destination === 'string' && !action.destination.startsWith('homeworld:') &&
|
||||
!!action.sources && Object.keys(action.sources as object).every((source) =>
|
||||
g.homeworlds?.worlds?.some((world) => world.id === source && world.native === me.id));
|
||||
const fromReserves =
|
||||
action.type === 'ship' ||
|
||||
(action.type === 'guildShip' && action.from === 'reserves');
|
||||
const shipment = fromReserves
|
||||
const shipment = junctionNative
|
||||
? {territory: splitLocation(String(action.destination)).territory,
|
||||
amount: Object.values(action.sources as Record<string, {normal: number; elite: number}>).reduce((n, source) => n + source.normal + source.elite, 0)}
|
||||
: fromReserves
|
||||
? { territory: String(action.territory), amount: Number(action.amount) }
|
||||
: null;
|
||||
return promises.every(
|
||||
@@ -3221,7 +3374,12 @@ export function runBots(state: Game, limit = 96): Game {
|
||||
: structuredClone(state);
|
||||
for (let step = 0; step < limit; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players.filter((p) => p.bot ?? p.autopilot)) {
|
||||
const actors = g.players.filter((p) => p.bot ?? p.autopilot);
|
||||
// Give an automated optional supplier a chance before a seated-earlier
|
||||
// recipient spends the shipment. Humans retain their ordinary actions.
|
||||
if (g.phase === 5 && junctionSponsor(g) && !currentJunctionOffer(g))
|
||||
actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild'));
|
||||
for (const p of actors) {
|
||||
for (const action of botActions(viewGame(g, p.id))) {
|
||||
try {
|
||||
next = applyAction(g, p.id, action);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** The resolved battle's projected opportunity is the sole client authority. */
|
||||
export function caladanReinforcementCanAct(g: GameView): boolean {
|
||||
return !!(
|
||||
g.status === 'playing' &&
|
||||
g.caladanReinforcement?.player === g.me &&
|
||||
g.decision?.kind === 'caladanReinforcement' &&
|
||||
g.decision.player === g.me &&
|
||||
g.decision.event === g.caladanReinforcement.event &&
|
||||
!g.response &&
|
||||
!g.truthtrance &&
|
||||
!g.phaseOpening &&
|
||||
!g.automaticContinuationPending
|
||||
);
|
||||
}
|
||||
|
||||
export function caladanReinforcementChoice(
|
||||
g: GameView,
|
||||
destination: string,
|
||||
): { blocked: string | null; action: Action | null } {
|
||||
const offer = g.caladanReinforcement;
|
||||
const target = offer?.destinations.find(
|
||||
(option) => option.id === destination,
|
||||
);
|
||||
let blocked: string | null = null;
|
||||
if (!caladanReinforcementCanAct(g))
|
||||
blocked = 'Wait for the current choice to finish.';
|
||||
else if (offer!.blocked) blocked = offer!.blocked;
|
||||
else if (offer!.amount !== 1)
|
||||
blocked = 'This battle reinforcement is no longer available.';
|
||||
else if (!target) blocked = 'Choose a current reinforcement destination.';
|
||||
else if (target.blocked) blocked = target.blocked;
|
||||
return {
|
||||
blocked,
|
||||
action: blocked
|
||||
? null
|
||||
: { type: 'decision', event: offer!.event, destination, amount: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Reinforce an already occupied sector when possible. No opposing private
|
||||
* information or reconstructed battle result is needed for this fallback. */
|
||||
export function caladanReinforcementActions(g: GameView): Action[] {
|
||||
if (!caladanReinforcementCanAct(g)) return [];
|
||||
const offer = g.caladanReinforcement!;
|
||||
const own = g.players.find((player) => player.id === g.me);
|
||||
const choices = offer.destinations
|
||||
.map((target) => ({
|
||||
target,
|
||||
action: caladanReinforcementChoice(g, target.id).action,
|
||||
}))
|
||||
.filter((choice) => choice.action);
|
||||
const occupied = choices.find(
|
||||
({ target }) =>
|
||||
(own?.forces[
|
||||
target.sector === undefined
|
||||
? target.id
|
||||
: `${offer.territory}:${target.sector}`
|
||||
] ?? 0) > 0,
|
||||
);
|
||||
return [
|
||||
occupied?.action ??
|
||||
choices[0]?.action ?? {
|
||||
type: 'decision',
|
||||
event: offer.event,
|
||||
decline: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { fighterCount } from './advisors';
|
||||
import { TERRITORIES } from './board';
|
||||
import { harvesterAvailable, type Card } from './cards';
|
||||
import type { FactionId } from './catalog';
|
||||
import { choamSaleGholaTiming } from './choam-market-ghola';
|
||||
|
||||
export type OrdinaryCardEffect =
|
||||
| 'weather'
|
||||
@@ -27,6 +28,7 @@ export type OrdinaryCardState = {
|
||||
phaseOpening?: { passed: readonly string[] } | null;
|
||||
response?: object | null;
|
||||
decision?: object | null;
|
||||
choamMarket?: { owner: string } | null;
|
||||
shieldWallDestroyed: boolean;
|
||||
spiceWindow?: {
|
||||
harvested: boolean;
|
||||
@@ -83,7 +85,7 @@ export function ordinaryCardAvailability(
|
||||
'Play Amal or pass the phase opening before taking other actions.',
|
||||
);
|
||||
}
|
||||
if (state.response)
|
||||
if (state.response && !(card?.effect === 'ghola' && choamSaleGholaTiming(state)))
|
||||
return blocked('Resolve the Karama response window first.');
|
||||
if (state.decision)
|
||||
return blocked('Waiting for the player with the pending decision.');
|
||||
|
||||
+20
-7
@@ -1,4 +1,5 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { homeworldLowBonus } from './homeworld-benefits';
|
||||
|
||||
export type Inflation = {
|
||||
side: 'double' | 'cancel';
|
||||
@@ -13,14 +14,26 @@ export function charityMultiplier(g: Pick<Game, 'turn' | 'inflation'>) {
|
||||
return g.inflation.side === 'double' ? 2 : 0;
|
||||
}
|
||||
|
||||
/** Inflation doubles the ordinary amount received, not the wealth threshold. */
|
||||
export function charityAmount(
|
||||
g: Pick<Game, 'advanced' | 'turn' | 'inflation'>,
|
||||
p: Pick<Player, 'spice' | 'faction'>,
|
||||
) {
|
||||
const amount =
|
||||
/** Separate ordinary funding from the Homeworld addition paid by the bank.
|
||||
* Inflation changes both eligible portions, never the ordinary wealth threshold.
|
||||
* This quote does not claim, pay, or resolve a Bene Gesserit power response. */
|
||||
export function charityQuote(
|
||||
g: Pick<Game, 'advanced' | 'turn' | 'inflation' | 'players' | 'homeworlds'>,
|
||||
p: Pick<Player, 'id' | 'spice' | 'faction'>,
|
||||
): { ordinary: number; homeworld: number; total: number } {
|
||||
const eligible =
|
||||
g.advanced && p.faction === 'beneGesserit' ? 2 : Math.max(0, 2 - p.spice);
|
||||
return amount * charityMultiplier(g);
|
||||
const multiplier = charityMultiplier(g);
|
||||
const ordinary = eligible * multiplier;
|
||||
const homeworld = eligible > 0 ? homeworldLowBonus(g, p.id) * multiplier : 0;
|
||||
return { ordinary, homeworld, total: ordinary + homeworld };
|
||||
}
|
||||
|
||||
export function charityAmount(
|
||||
g: Pick<Game, 'advanced' | 'turn' | 'inflation' | 'players' | 'homeworlds'>,
|
||||
p: Pick<Player, 'id' | 'spice' | 'faction'>,
|
||||
) {
|
||||
return charityQuote(g, p).total;
|
||||
}
|
||||
|
||||
export function charityPayer(
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { Game, Player } from './engine';
|
||||
import { retentionReservesCard } from './moritani-retention';
|
||||
/** Only cards not already fixed into a battle may be converted into spice. */
|
||||
export function cashInCards(
|
||||
g: Pick<Game, 'battle' | 'moritaniRetention'>,
|
||||
g: Pick<Game, 'battle' | 'moritaniRetention' | 'pendingWinnerDiscards'>,
|
||||
p: Pick<Player, 'id' | 'hand'>,
|
||||
) {
|
||||
const plan = g.battle?.plans[p.id];
|
||||
const insight = g.battle?.prescience;
|
||||
return p.hand.filter(
|
||||
(c) =>
|
||||
!(g.pendingWinnerDiscards?.player === p.id &&
|
||||
[...g.pendingWinnerDiscards.cards, ...g.pendingWinnerDiscards.optional].includes(c.id)) &&
|
||||
!retentionReservesCard(g, p.id, c.id) &&
|
||||
(!plan || ![plan.weapon, plan.defense, plan.leader].includes(c.id)) &&
|
||||
(!insight || insight.player === p.id || insight.value !== c.id),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Game, ResponseWindow } from './engine';
|
||||
import type { ChoamMarket } from './choam-market';
|
||||
import { quoteChoamSaleCancellation } from './choam-sale-cancellation';
|
||||
|
||||
/** The sale is suspended, never replayed. Its physical card stays in its hand. */
|
||||
export type ChoamMarketGhola = {
|
||||
turn: number;
|
||||
phase: number;
|
||||
player: string;
|
||||
card: string;
|
||||
discardSequence: number;
|
||||
event: string;
|
||||
stage: 'discard' | 'income' | 'complete';
|
||||
response: ResponseWindow;
|
||||
market: ChoamMarket;
|
||||
};
|
||||
|
||||
export function choamGholaEvent(pending: Pick<ChoamMarketGhola,
|
||||
'turn' | 'phase' | 'player' | 'card' | 'discardSequence'>) {
|
||||
return JSON.stringify(['choamGhola', pending.turn, pending.phase,
|
||||
pending.player, pending.card, pending.discardSequence]);
|
||||
}
|
||||
|
||||
/** Public timing shared by the server and the hand's ordinary-card controls. */
|
||||
export function choamSaleGholaTiming(state: {
|
||||
status: string;
|
||||
response?: object | null;
|
||||
decision?: object | null;
|
||||
choamMarket?: { owner: string } | null;
|
||||
}) {
|
||||
const response = state.response as { kind?: string; owner?: string } | null;
|
||||
return state.status === 'playing' &&
|
||||
response?.kind === 'choamSale' &&
|
||||
response.owner === state.choamMarket?.owner && !state.decision;
|
||||
}
|
||||
|
||||
/** No current hand or population requirement: a nested effect may legitimately
|
||||
* move the sale card or change Tupile before the original declaration resumes. */
|
||||
export function choamMarketGholaError(g: Game): string | null {
|
||||
const pending = g.pendingChoamMarketGhola;
|
||||
if (!pending) return null;
|
||||
if (
|
||||
g.status !== 'playing' || pending.turn !== g.turn || pending.phase !== g.phase ||
|
||||
!g.players.some((p) => p.id === pending.player) ||
|
||||
typeof pending.card !== 'string' || !pending.card ||
|
||||
!Number.isSafeInteger(pending.discardSequence) || pending.discardSequence < 1 ||
|
||||
pending.discardSequence > (g.treacheryDiscardSequence ?? 0) ||
|
||||
pending.event !== choamGholaEvent(pending) ||
|
||||
!['discard', 'income', 'complete'].includes(pending.stage) ||
|
||||
pending.response?.kind !== 'choamSale' ||
|
||||
!Array.isArray(pending.response.passed) ||
|
||||
new Set(pending.response.passed).size !== pending.response.passed.length ||
|
||||
pending.response.passed.some((id) => !g.players.some((p) => p.id === id)) ||
|
||||
JSON.stringify(pending.market) !== JSON.stringify(g.choamMarket)
|
||||
) return 'The saved Ghola interruption no longer matches its CHOAM sale.';
|
||||
const discard = g.pendingTreacheryDiscard;
|
||||
if (
|
||||
(g.resolvedTreacheryDiscardSequence ?? 0) < pending.discardSequence &&
|
||||
!(discard?.sequence === pending.discardSequence &&
|
||||
discard.continuation.kind === 'ordinaryCardDiscard' &&
|
||||
discard.continuation.effect === 'ghola' &&
|
||||
discard.continuation.player === pending.player &&
|
||||
discard.continuation.card === pending.card)
|
||||
) return 'The saved market interruption has no matching committed Ghola discard.';
|
||||
if (pending.stage === 'discard' && discard?.sequence !== pending.discardSequence)
|
||||
return 'The saved Ghola discard stage has no matching continuation.';
|
||||
if (pending.stage !== 'discard' &&
|
||||
(g.resolvedTreacheryDiscardSequence ?? 0) < pending.discardSequence)
|
||||
return 'The suspended market is waiting for its committed Ghola discard.';
|
||||
const continuation = discard?.continuation;
|
||||
const heldRevival = g.homeworldRevivalReturn;
|
||||
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume,
|
||||
g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume,
|
||||
g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null,
|
||||
heldRevival && ['choice', 'arrival'].includes(heldRevival.stage)
|
||||
? { response: heldRevival.resumeResponse } : null];
|
||||
const incomes = contexts.flatMap((context) => {
|
||||
if (!context) return [];
|
||||
const karama = 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [context.response, karama?.use?.kind === 'cancel' ? karama.use.response : null];
|
||||
}).filter((response) => response?.kind === 'revivalIncome' && response.intent === pending.event);
|
||||
if (pending.stage === 'income' && !incomes.length)
|
||||
return 'The saved Ghola income has lost its suspended market receipt.';
|
||||
if (pending.stage === 'complete' && incomes.length)
|
||||
return 'Finish the Ghola income before restoring its sale.';
|
||||
if (incomes.some((income) => income!.recipient !== pending.player || income!.amount !== 1 ||
|
||||
!g.players.some((p) => p.id === income!.owner && p.faction === 'tleilaxu')))
|
||||
return 'The saved Ghola income no longer matches the revived faction.';
|
||||
try {
|
||||
quoteChoamSaleCancellation(g, pending.response);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Invalid suspended CHOAM sale.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Game } from './engine';
|
||||
import { gameTerritories } from './board';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import {
|
||||
homeworldCombatLocation,
|
||||
quoteHomeworldBattles,
|
||||
} from './homeworld-combat';
|
||||
import { homeworldLocations, type HomeworldForces } from './homeworld-custody';
|
||||
import {
|
||||
quoteBattleBoard,
|
||||
quoteBattleBoardContinuation,
|
||||
} from './board-resolution-quote';
|
||||
|
||||
export type PublicCombatLocation = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'territory' | 'homeworld';
|
||||
native?: string;
|
||||
nativeBattleStrength?: number;
|
||||
forces?: Record<string, HomeworldForces>;
|
||||
};
|
||||
export function homeworldBattleLocation(g: Game, id: string) {
|
||||
if (!id.startsWith('homeworld:')) return null;
|
||||
if (!g.homeworlds?.custody)
|
||||
throw new Error('Homeworld combat requires saved Homeworld custody.');
|
||||
const context = homeworldContext(g);
|
||||
return homeworldCombatLocation(
|
||||
{
|
||||
...context,
|
||||
order: g.order,
|
||||
players: context.players.map((p) => ({
|
||||
...p,
|
||||
ally: g.players.find((seat) => seat.id === p.id)!.ally,
|
||||
})),
|
||||
},
|
||||
g.homeworlds.custody,
|
||||
id,
|
||||
);
|
||||
}
|
||||
export function combatLocations(g: Game): PublicCombatLocation[] {
|
||||
const board: PublicCombatLocation[] = gameTerritories(g).map(
|
||||
({ id, name }) => ({ id, name, kind: 'territory' }),
|
||||
);
|
||||
if (!g.homeworlds?.custody) return board;
|
||||
return [
|
||||
...board,
|
||||
...homeworldLocations(homeworldContext(g)).map(({ id }) => {
|
||||
const home = homeworldBattleLocation(g, id)!;
|
||||
return {
|
||||
id,
|
||||
name: home.name,
|
||||
kind: 'homeworld' as const,
|
||||
native: home.native,
|
||||
nativeBattleStrength: home.nativeBattleStrength,
|
||||
forces: home.forces,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
export function combatLocationName(g: Game, id: string): string {
|
||||
const home = homeworldBattleLocation(g, id);
|
||||
const name = home?.name ?? gameTerritories(g).find((t) => t.id === id)?.name;
|
||||
if (!name) throw new Error('Choose a current combat location.');
|
||||
return name;
|
||||
}
|
||||
export function combatArmy(
|
||||
g: Game,
|
||||
player: string,
|
||||
id: string,
|
||||
): HomeworldForces {
|
||||
const home = homeworldBattleLocation(g, id);
|
||||
if (home) return { ...(home.forces[player] ?? { normal: 0, elite: 0 }) };
|
||||
combatLocationName(g, id);
|
||||
const p = g.players.find((p) => p.id === player);
|
||||
if (!p) throw new Error('Choose a seated combatant.');
|
||||
const count = (map: Record<string, number>) =>
|
||||
Object.entries(map).reduce(
|
||||
(sum, [key, n]) =>
|
||||
sum + (key.slice(0, key.lastIndexOf(':')) === id ? n : 0),
|
||||
0,
|
||||
);
|
||||
const elite = count(p.elites?.forces ?? {});
|
||||
return { normal: count(p.forces) - elite, elite };
|
||||
}
|
||||
export function quoteCombatBoard(g: Game) {
|
||||
const board = quoteBattleBoard(g);
|
||||
if (!g.homeworlds?.custody) return board;
|
||||
const context = homeworldContext(g);
|
||||
const worlds = quoteHomeworldBattles(
|
||||
{
|
||||
...context,
|
||||
order: g.order,
|
||||
players: context.players.map((p) => ({
|
||||
...p,
|
||||
ally: g.players.find((seat) => seat.id === p.id)!.ally,
|
||||
})),
|
||||
},
|
||||
g.homeworlds.custody,
|
||||
);
|
||||
return {
|
||||
...board,
|
||||
battles: [...board.battles, ...worlds].sort(
|
||||
(a, b) => g.order.indexOf(a.attacker) - g.order.indexOf(b.attacker),
|
||||
),
|
||||
};
|
||||
}
|
||||
export function quoteCombatBoardContinuation(g: Game) {
|
||||
const board = quoteCombatBoard(g);
|
||||
return board.battles.length
|
||||
? { board, phase: null }
|
||||
: quoteBattleBoardContinuation(g);
|
||||
}
|
||||
+31
-3
@@ -5,6 +5,9 @@ export type CombatForces = {
|
||||
elite: number;
|
||||
eliteStrength: 1 | 2;
|
||||
freeSupport: boolean;
|
||||
/** Special counters retain full strength without paying support; normal
|
||||
* counters still follow their ordinary support rules. */
|
||||
eliteFreeSupport?: boolean;
|
||||
};
|
||||
export type Casualties = {
|
||||
normal: number;
|
||||
@@ -13,6 +16,26 @@ export type Casualties = {
|
||||
paidElite: number;
|
||||
};
|
||||
|
||||
/** Current supported factions have at most twenty physical counters. */
|
||||
export function validCombatForces(forces: CombatForces): boolean {
|
||||
return (
|
||||
!!forces &&
|
||||
typeof forces === 'object' &&
|
||||
!Array.isArray(forces) &&
|
||||
Number.isSafeInteger(forces.normal) &&
|
||||
forces.normal >= 0 &&
|
||||
Number.isSafeInteger(forces.elite) &&
|
||||
forces.elite >= 0 &&
|
||||
forces.normal + forces.elite <= 20 &&
|
||||
(forces.eliteStrength === 1 || forces.eliteStrength === 2) &&
|
||||
typeof forces.freeSupport === 'boolean' &&
|
||||
(forces.eliteFreeSupport === undefined ||
|
||||
typeof forces.eliteFreeSupport === 'boolean') &&
|
||||
(forces.normalFixedHalf === undefined ||
|
||||
typeof forces.normalFixedHalf === 'boolean')
|
||||
);
|
||||
}
|
||||
|
||||
/** Every loss combination consistent with the revealed dial and spice payment. */
|
||||
export function casualtyOptions(
|
||||
forces: CombatForces,
|
||||
@@ -20,11 +43,13 @@ export function casualtyOptions(
|
||||
support: number,
|
||||
): Casualties[] {
|
||||
if (
|
||||
!validCombatForces(forces) ||
|
||||
!Number.isFinite(dial) ||
|
||||
dial < 0 ||
|
||||
!Number.isInteger(dial * 2) ||
|
||||
!Number.isInteger(support) ||
|
||||
!Number.isSafeInteger(support) ||
|
||||
support < 0 ||
|
||||
support > forces.normal + (forces.eliteFreeSupport ? 0 : forces.elite) ||
|
||||
(forces.freeSupport && support !== 0)
|
||||
)
|
||||
return [];
|
||||
@@ -42,13 +67,16 @@ export function casualtyOptions(
|
||||
}
|
||||
for (
|
||||
let paidElite = Math.max(0, support - normal);
|
||||
paidElite <= Math.min(elite, support);
|
||||
paidElite <= (forces.eliteFreeSupport ? 0 : Math.min(elite, support));
|
||||
paidElite++
|
||||
) {
|
||||
const paidNormal = support - paidElite;
|
||||
if (forces.normalFixedHalf && paidNormal !== 0) continue;
|
||||
const doubledStrength =
|
||||
normal + paidNormal + (elite + paidElite) * forces.eliteStrength;
|
||||
normal +
|
||||
paidNormal +
|
||||
(forces.eliteFreeSupport ? elite * 2 : elite + paidElite) *
|
||||
forces.eliteStrength;
|
||||
if (doubledStrength === dial * 2) {
|
||||
options.push({ normal, elite, paidNormal, paidElite });
|
||||
break; // Different support allocations with identical token losses are equivalent choices.
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ export const DUKE_VIDAL_RULES = Object.freeze({
|
||||
capture: 'not-implemented',
|
||||
revival: 'not-implemented',
|
||||
ambassadorAcquisition: 'ecaz-self-implemented',
|
||||
ambassadorAlliance: 'not-implemented',
|
||||
ambassadorAlliance: 'consent-implemented',
|
||||
ambassadorAllyAcquisition: 'unresolved',
|
||||
allyLoanReturn: 'unresolved',
|
||||
sourceAudit: 'docs/DUKE_VIDAL_RULES.md',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import type { Game } from './engine';
|
||||
|
||||
export type EcazAllianceSeat = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
ally: string | null;
|
||||
allySinceTurn?: number;
|
||||
};
|
||||
export type EcazAllianceContext = {
|
||||
status: Game['status'];
|
||||
turn: number;
|
||||
players: readonly Readonly<EcazAllianceSeat>[];
|
||||
allianceOffers: Readonly<Record<string, string>>;
|
||||
};
|
||||
export class EcazAllianceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EcazAllianceError';
|
||||
}
|
||||
}
|
||||
function requireAlliance(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new EcazAllianceError(message);
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const text = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
/** Quotes only public alliance identity. The caller owns Ambassador provenance,
|
||||
* consent, token commitment and continuation; Duke custody is independent. */
|
||||
export function quoteEcazAlliance(
|
||||
context: EcazAllianceContext,
|
||||
ownerId: string,
|
||||
entrantId: string,
|
||||
): { players: EcazAllianceSeat[]; allianceOffers: Record<string, string> } {
|
||||
requireAlliance(
|
||||
record(context) &&
|
||||
context.status === 'playing' &&
|
||||
Number.isSafeInteger(context.turn) &&
|
||||
context.turn >= 1,
|
||||
'An Ecaz alliance requires a playing game and a valid turn.',
|
||||
);
|
||||
const seats = context.players;
|
||||
requireAlliance(
|
||||
Array.isArray(seats) &&
|
||||
seats.length >= 2 &&
|
||||
seats.every(
|
||||
(seat) =>
|
||||
record(seat) &&
|
||||
text(seat.id) &&
|
||||
FACTIONS.some((faction) => faction.id === seat.faction) &&
|
||||
(seat.ally === null || text(seat.ally)) &&
|
||||
(seat.allySinceTurn === undefined ||
|
||||
(typeof seat.allySinceTurn === 'number' &&
|
||||
Number.isSafeInteger(seat.allySinceTurn) &&
|
||||
seat.allySinceTurn >= 0 &&
|
||||
seat.allySinceTurn <= context.turn)),
|
||||
) &&
|
||||
new Set(seats.map((seat) => seat.id)).size === seats.length &&
|
||||
new Set(seats.map((seat) => seat.faction)).size === seats.length,
|
||||
'An Ecaz alliance requires unique valid seated identities and factions.',
|
||||
);
|
||||
const byId = new Map(seats.map((seat) => [seat.id, seat]));
|
||||
for (const seat of seats) {
|
||||
if (seat.ally === null) continue;
|
||||
requireAlliance(
|
||||
seat.ally !== seat.id && byId.get(seat.ally)?.ally === seat.id,
|
||||
'Existing alliances must be seated, distinct and reciprocal.',
|
||||
);
|
||||
}
|
||||
const owner = byId.get(ownerId);
|
||||
const entrant = byId.get(entrantId);
|
||||
requireAlliance(
|
||||
text(ownerId) &&
|
||||
text(entrantId) &&
|
||||
owner &&
|
||||
entrant &&
|
||||
ownerId !== entrantId &&
|
||||
owner.faction === 'ecaz',
|
||||
'Choose the seated Ecaz owner and a different seated entrant.',
|
||||
);
|
||||
requireAlliance(
|
||||
owner.ally === null && entrant.ally === null,
|
||||
'Ecaz and the entrant must both be unallied.',
|
||||
);
|
||||
requireAlliance(
|
||||
record(context.allianceOffers),
|
||||
'Alliance offers must be a valid public record.',
|
||||
);
|
||||
const offers = Object.entries(context.allianceOffers);
|
||||
requireAlliance(
|
||||
offers.every(
|
||||
([from, to]) => text(to) && from !== to && byId.has(from) && byId.has(to),
|
||||
),
|
||||
'Alliance offers must connect distinct seated players.',
|
||||
);
|
||||
const pair = new Set([ownerId, entrantId]);
|
||||
return {
|
||||
players: seats.map((seat) => {
|
||||
const result: EcazAllianceSeat = {
|
||||
id: seat.id,
|
||||
faction: seat.faction,
|
||||
ally: seat.ally,
|
||||
};
|
||||
if (seat.allySinceTurn !== undefined)
|
||||
result.allySinceTurn = seat.allySinceTurn;
|
||||
if (pair.has(seat.id)) {
|
||||
result.ally = seat.id === ownerId ? entrantId : ownerId;
|
||||
result.allySinceTurn = context.turn;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
allianceOffers: Object.fromEntries(
|
||||
offers.filter(([from, to]) => !pair.has(from) && !pair.has(to)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function ecazAllianceBlock(
|
||||
context: EcazAllianceContext,
|
||||
ownerId: string,
|
||||
entrantId: string,
|
||||
): string | null {
|
||||
try {
|
||||
quoteEcazAlliance(context, ownerId, entrantId);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof EcazAllianceError) return error.message;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Card } from './cards';
|
||||
import type { Game } from './engine';
|
||||
import type { EffectiveWeapon } from './effective-weapons';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
|
||||
export type EcazPoisonDiscard = {
|
||||
card: Card;
|
||||
/** The actual validated plan slot at disposal, not a possible card role. */
|
||||
battleSlot?: 'weapon' | 'defense' | 'leader';
|
||||
/** Retained validated copy result for a played Mirror, never a client claim. */
|
||||
effectiveWeapon?: EffectiveWeapon;
|
||||
};
|
||||
export class EcazPoisonIncomeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EcazPoisonIncomeError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Current native population only. The caller proves the physical discard and
|
||||
* pays the quote once; neither card custody nor balances are changed here.
|
||||
* Source composition and retained-role contract: ECAZ_POISON_INCOME_RULES.md. */
|
||||
export function quoteEcazPoisonIncome(
|
||||
context: Pick<Game, 'advanced' | 'players' | 'homeworlds'>,
|
||||
discarded: readonly EcazPoisonDiscard[],
|
||||
): { player: string; amount: number; count: number } | null {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of discarded) {
|
||||
if (
|
||||
!entry?.card ||
|
||||
typeof entry.card.id !== 'string' ||
|
||||
!entry.card.id ||
|
||||
ids.has(entry.card.id) ||
|
||||
(entry.battleSlot !== undefined &&
|
||||
!['weapon', 'defense', 'leader'].includes(entry.battleSlot))
|
||||
)
|
||||
throw new EcazPoisonIncomeError(
|
||||
'Poison income needs distinct physical discards and valid original battle slots.',
|
||||
);
|
||||
ids.add(entry.card.id);
|
||||
}
|
||||
if (!context.homeworlds?.custody || !discarded.length) return null;
|
||||
const home = homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).find((candidate) => candidate.card === 'ecaz' && candidate.side === 'high');
|
||||
if (!home) return null;
|
||||
let count = 0;
|
||||
for (const { card, battleSlot, effectiveWeapon } of discarded) {
|
||||
const richese = richeseCardDefinition(card);
|
||||
if (richese?.card.effect === 'mirrorWeapon' && battleSlot === 'weapon') {
|
||||
if (
|
||||
!effectiveWeapon ||
|
||||
effectiveWeapon.physicalId !== card.id ||
|
||||
(effectiveWeapon.kind === null
|
||||
? effectiveWeapon.copiedFrom !== null
|
||||
: typeof effectiveWeapon.copiedFrom !== 'string' ||
|
||||
!effectiveWeapon.copiedFrom ||
|
||||
effectiveWeapon.copiedFrom === card.id ||
|
||||
![
|
||||
'projectile',
|
||||
'poison',
|
||||
'lasgun',
|
||||
'poisonBlade',
|
||||
'poisonTooth',
|
||||
'chemistry',
|
||||
'weirdingWay',
|
||||
'artillery',
|
||||
'stoneBurner',
|
||||
].includes(effectiveWeapon.kind))
|
||||
)
|
||||
throw new EcazPoisonIncomeError(
|
||||
'The discarded Mirror needs its retained validated copied weapon role.',
|
||||
);
|
||||
if (
|
||||
['poison', 'poisonBlade', 'poisonTooth', 'chemistry'].includes(
|
||||
effectiveWeapon.kind ?? '',
|
||||
)
|
||||
)
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
// Richese physical identities cannot become ordinary poison through a
|
||||
// forged kind or a name containing "Poison".
|
||||
if (richese || card.id.startsWith('richese-')) continue;
|
||||
if (
|
||||
card.kind === 'poison' ||
|
||||
card.kind === 'poisonBlade' ||
|
||||
card.kind === 'poisonTooth' ||
|
||||
(card.kind === 'chemistry' && battleSlot === 'weapon')
|
||||
)
|
||||
count++;
|
||||
}
|
||||
return count ? { player: home.native, amount: 3 * count, count } : null;
|
||||
}
|
||||
+2413
-266
File diff suppressed because it is too large.
Load diff
-16113
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@ import { arrivalAsAdvisor } from './advisors';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { ecazOccupancyRelation } from './ecaz-occupy';
|
||||
import { homeworldNoFieldMovementBlock } from './homeworld-mobility';
|
||||
|
||||
export class FremenAmbassadorMoveError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -176,6 +177,8 @@ export function quoteFremenAmbassadorMove(
|
||||
let origin: string | undefined;
|
||||
let noField: FremenAmbassadorMove['noField'];
|
||||
if (action.noField !== undefined) {
|
||||
const blocked = homeworldNoFieldMovementBlock(g, p.id);
|
||||
requireMove(!blocked, blocked ?? 'This No-Field cannot move.');
|
||||
const marker = ownMarker(p);
|
||||
requireMove(
|
||||
record(action.noField) &&
|
||||
@@ -339,7 +342,7 @@ export function fremenAmbassadorMovement(
|
||||
beneficiaryId: string,
|
||||
): FremenAmbassadorMovement {
|
||||
const p = playerFor(g, beneficiaryId);
|
||||
const marker = ownMarker(p);
|
||||
const marker = homeworldNoFieldMovementBlock(g, p.id) ? null : ownMarker(p);
|
||||
const sources: FremenAmbassadorMovement['sources'] = [];
|
||||
for (const t of gameTerritories(g)) {
|
||||
const sectors = Object.entries(p.forces)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
quoteGiediCollectionBonus,
|
||||
type HomeworldCollectionContext,
|
||||
type HomeworldCollectionSource,
|
||||
} from './homeworld-collection';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
|
||||
export type GiediCollection = {
|
||||
turn: number;
|
||||
player: string;
|
||||
qualifying: number;
|
||||
awarded: boolean;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
function signature(receipt: Omit<GiediCollection, 'signature'>) {
|
||||
return JSON.stringify([
|
||||
receipt.turn,
|
||||
receipt.player,
|
||||
receipt.qualifying,
|
||||
receipt.awarded,
|
||||
]);
|
||||
}
|
||||
|
||||
/** A saved receipt records completed income, never an instruction to pay again.
|
||||
* Missing receipts remain compatible with older saves; do not reconstruct one
|
||||
* from present balances, forces or already-collected deposits. */
|
||||
export function validateGiediCollection(
|
||||
context: HomeworldCollectionContext,
|
||||
turn: number,
|
||||
receipt: GiediCollection | undefined,
|
||||
) {
|
||||
if (receipt === undefined) return;
|
||||
if (
|
||||
!receipt ||
|
||||
typeof receipt !== 'object' ||
|
||||
Array.isArray(receipt) ||
|
||||
Object.keys(receipt).sort().join(',') !==
|
||||
'awarded,player,qualifying,signature,turn' ||
|
||||
!context.homeworlds?.custody ||
|
||||
!context.players.some(
|
||||
(p) => p.id === receipt.player && p.faction === 'harkonnen',
|
||||
) ||
|
||||
!Number.isSafeInteger(receipt.turn) ||
|
||||
receipt.turn < 1 ||
|
||||
receipt.turn > turn ||
|
||||
!Number.isSafeInteger(receipt.qualifying) ||
|
||||
receipt.qualifying < 0 ||
|
||||
typeof receipt.awarded !== 'boolean' ||
|
||||
(receipt.awarded && receipt.qualifying === 0) ||
|
||||
receipt.signature !== signature(receipt)
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Giedi Prime collection receipt is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
export function quoteGiediCollectionReceipt(
|
||||
context: HomeworldCollectionContext,
|
||||
turn: number,
|
||||
player: string,
|
||||
sources: HomeworldCollectionSource[],
|
||||
previous?: GiediCollection,
|
||||
): { receipt: GiediCollection; amount: 0 | 2 } {
|
||||
validateGiediCollection(context, turn, previous);
|
||||
if (!Number.isSafeInteger(turn) || turn < 1 || !context.homeworlds?.custody)
|
||||
throw new HomeworldCustodyError(
|
||||
'Giedi Prime collection needs its Homeworld and current turn.',
|
||||
);
|
||||
const current = previous?.turn === turn ? previous : undefined;
|
||||
const quote = quoteGiediCollectionBonus(
|
||||
context,
|
||||
player,
|
||||
sources,
|
||||
current?.awarded ?? false,
|
||||
);
|
||||
const qualifying = (current?.qualifying ?? 0) + quote.qualifying;
|
||||
if (!Number.isSafeInteger(qualifying))
|
||||
throw new HomeworldCustodyError(
|
||||
'Giedi Prime collection receipts would overflow.',
|
||||
);
|
||||
const receipt = {
|
||||
turn,
|
||||
player,
|
||||
qualifying,
|
||||
awarded: (current?.awarded ?? false) || quote.amount > 0,
|
||||
};
|
||||
return {
|
||||
receipt: { ...receipt, signature: signature(receipt) },
|
||||
amount: quote.amount,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validAmbassadorResume } from './ambassador-resume';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import type { Game } from './engine';
|
||||
import type { GuildAmbassadorShipment } from './guild-ambassador';
|
||||
import { validLocation, territory } from './board';
|
||||
@@ -18,6 +19,11 @@ export type GuildAmbassadorArrivalContext = Pick<
|
||||
| 'players'
|
||||
| 'pendingAmbassador'
|
||||
| 'ecazAmbassadors'
|
||||
| 'homeworlds'
|
||||
| 'homeworldRevivalReturn'
|
||||
| 'homeworldRevivalProgress'
|
||||
| 'homeworldVictoryReinforcement'
|
||||
| 'lastBattleContext'
|
||||
>;
|
||||
export type GuildAmbassadorArrivalNext =
|
||||
| 'intrusion'
|
||||
@@ -61,7 +67,7 @@ export function validateGuildAmbassadorArrivalContext(
|
||||
g.status === 'playing' &&
|
||||
whole(g.turn) &&
|
||||
g.turn > 0 &&
|
||||
(g.phase === 1 || g.phase === 5) &&
|
||||
ambassadorPhaseAllowed(g) &&
|
||||
Array.isArray(g.players) &&
|
||||
g.players.every((p) => id(p.id)) &&
|
||||
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
||||
@@ -151,7 +157,11 @@ export function validateGuildAmbassadorArrivalContext(
|
||||
bg?.faction === 'beneGesserit' &&
|
||||
bg.id !== order.player &&
|
||||
g.players.filter((p) => p.faction === 'beneGesserit').length === 1 &&
|
||||
advisorArrival.amount === 1 &&
|
||||
(advisorArrival.amount === 1 ||
|
||||
(advisorArrival.amount === 2 &&
|
||||
!!g.homeworlds?.custody &&
|
||||
advisorArrival.territory === 'polar_sink' &&
|
||||
advisorArrival.sector === 0)) &&
|
||||
advisorArrival.elite === 0 &&
|
||||
boardLocation(advisorArrival.territory, advisorArrival.sector) &&
|
||||
(advisorArrival.territory === order.territory ||
|
||||
@@ -159,6 +169,9 @@ export function validateGuildAmbassadorArrivalContext(
|
||||
advisorArrival.sector === 0)),
|
||||
'The saved accompanying advisor does not match this shipment destination and owner.',
|
||||
);
|
||||
// This is a committed arrival: the batch itself or later child effects can
|
||||
// lower Wallach below high. Rechecking today's allowance would invalidate
|
||||
// legal historical receipts. Declaration/commit prove the original limit.
|
||||
}
|
||||
return { order, ...(advisorArrival ? { advisorArrival } : {}) };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { homeworldSpiritualAdvisorQuote } from './homeworld-mobility';
|
||||
|
||||
export class GuildAmbassadorShipmentError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -251,7 +252,7 @@ export type GuildAmbassadorAdvisor = {
|
||||
player: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
amount: 1;
|
||||
amount: 1 | 2;
|
||||
elite: 0;
|
||||
advisors: boolean;
|
||||
};
|
||||
@@ -306,15 +307,26 @@ export function quoteGuildAmbassadorAdvisor(
|
||||
? shipment.sector
|
||||
: (action.sector as number)
|
||||
: 0;
|
||||
const allowance = homeworldSpiritualAdvisorQuote(g, bg.id, to);
|
||||
requireShipment(
|
||||
!allowance.blocked,
|
||||
allowance.blocked ?? 'Accompaniment is unavailable.',
|
||||
);
|
||||
const amount = action.amount === undefined ? 1 : action.amount;
|
||||
requireShipment(
|
||||
(amount === 1 || amount === 2) &&
|
||||
amount <= allowance.maximum &&
|
||||
amount <= bg.reserves - (bg.elites?.reserves ?? 0),
|
||||
'Choose an available spiritual-advisor amount; two requires high-population Wallach IX and Polar Sink.',
|
||||
);
|
||||
requireShipment(
|
||||
(action.territory === undefined || action.territory === to) &&
|
||||
(action.amount === undefined || action.amount === 1) &&
|
||||
(action.elite === undefined || action.elite === 0) &&
|
||||
action.noField === undefined &&
|
||||
action.alliedNoField === undefined &&
|
||||
usableLocation(g, location(to, sector)) &&
|
||||
(sector === 0 || sector !== g.storm),
|
||||
'This accompaniment grants one ordinary force only to the shipment territory or Polar Sink, outside storm.',
|
||||
'Accompaniment sends ordinary forces only to the shipment territory or Polar Sink, outside storm.',
|
||||
);
|
||||
// E1 p.10 FAQ explicitly allows BG to accompany Ixians into the HMS. This
|
||||
// grants no independent BG direct shipment or relocation of the stronghold.
|
||||
@@ -325,15 +337,15 @@ export function quoteGuildAmbassadorAdvisor(
|
||||
const advisors = arrivalAsAdvisor(g, bg, to, undefined, accompanying);
|
||||
validateDestinationOccupancy(g, bg, to, advisors);
|
||||
requireShipment(
|
||||
count((bg.forces[location(to, sector)] ?? 0) + 1) &&
|
||||
count(presenceAt(bg, to) + 1),
|
||||
count((bg.forces[location(to, sector)] ?? 0) + amount) &&
|
||||
count(presenceAt(bg, to) + amount),
|
||||
'The resulting Bene Gesserit force quantity is invalid.',
|
||||
);
|
||||
return {
|
||||
player: bg.id,
|
||||
territory: to,
|
||||
sector,
|
||||
amount: 1,
|
||||
amount,
|
||||
elite: 0,
|
||||
advisors,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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 ||
|
||||
me.shipped
|
||||
) {
|
||||
result.blocked = 'Use Guild transport during your unused shipment turn.';
|
||||
return result;
|
||||
}
|
||||
if (option.blocked) {
|
||||
result.blocked = option.blocked;
|
||||
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,
|
||||
storm: view.storm,
|
||||
mobileStronghold: view.mobileStronghold?.location ?? null,
|
||||
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 ||
|
||||
me.shipped ||
|
||||
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 * 2,
|
||||
[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;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { MOBILE_LOCATION, splitLocation, validLocation } from './board';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
type HomeworldForces,
|
||||
} from './homeworld-custody';
|
||||
import type { HomeworldShipmentContext } from './homeworld-shipment';
|
||||
|
||||
export type GuildHomeworldShipmentContext = HomeworldShipmentContext & {
|
||||
storm: number;
|
||||
board: Record<
|
||||
string,
|
||||
{ forces: Record<string, number>; eliteForces: Record<string, number> }
|
||||
>;
|
||||
/** The mobile stronghold's ordinary Arrakis pointer, not its interior. */
|
||||
mobileStronghold?: string | null;
|
||||
};
|
||||
export type GuildHomeworldShipmentIntent = {
|
||||
player: string;
|
||||
destination: string;
|
||||
sources: Record<string, HomeworldForces>;
|
||||
};
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
function requireShipment(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(message);
|
||||
}
|
||||
function canonicalSector(key: string) {
|
||||
const { territory, sector } = splitLocation(key);
|
||||
return key === `${territory}:${sector}` && validLocation(territory, sector);
|
||||
}
|
||||
|
||||
/** Guild's own special shipment from one Arrakis territory to a Homeworld.
|
||||
* The caller owns timing, payment, alliance funding, responses and arrival
|
||||
* effects. No private card, concealed-force value or transport usage is read.
|
||||
* The mobile stronghold's interior remains protected when its pointer is in
|
||||
* storm. A Homeworld destination never becomes a fabricated Arrakis sector. */
|
||||
export function quoteGuildHomeworldShipment(
|
||||
context: GuildHomeworldShipmentContext,
|
||||
custody: HomeworldCustody,
|
||||
intent: GuildHomeworldShipmentIntent,
|
||||
) {
|
||||
const worlds = homeworldForceGroups(context, custody);
|
||||
requireShipment(
|
||||
whole(context.storm) && context.storm <= 18 && record(context.board),
|
||||
'Guild shipment needs the current board and storm sector.',
|
||||
);
|
||||
requireShipment(
|
||||
context.players.every(
|
||||
(p) =>
|
||||
p.ally === null ||
|
||||
(typeof p.ally === 'string' &&
|
||||
p.ally !== p.id &&
|
||||
context.players.some((other) => other.id === p.ally)),
|
||||
),
|
||||
'Homeworld shipment needs valid seated alliance identities.',
|
||||
);
|
||||
requireShipment(
|
||||
record(intent) &&
|
||||
Object.keys(intent).length === 3 &&
|
||||
typeof intent.player === 'string' &&
|
||||
typeof intent.destination === 'string' &&
|
||||
record(intent.sources),
|
||||
'Choose the Guild, a destination Homeworld and typed Arrakis source groups.',
|
||||
);
|
||||
const player = context.players.find((p) => p.id === intent.player);
|
||||
const destination = worlds.find((w) => w.id === intent.destination);
|
||||
requireShipment(
|
||||
player?.faction === 'guild' && destination,
|
||||
'Only the Guild may ship from Arrakis to an active Homeworld.',
|
||||
);
|
||||
const native = context.players.find((p) => p.id === destination.native)!;
|
||||
requireShipment(
|
||||
!(player.ally === native.id && native.ally === player.id),
|
||||
'You cannot ship to your ally’s Homeworld.',
|
||||
);
|
||||
const board = Object.hasOwn(context.board, player.id)
|
||||
? context.board[player.id]
|
||||
: undefined;
|
||||
requireShipment(
|
||||
record(board) && record(board.forces) && record(board.eliteForces),
|
||||
'Guild shipment needs its current physical board counters.',
|
||||
);
|
||||
const pointer = context.mobileStronghold;
|
||||
requireShipment(
|
||||
pointer === undefined ||
|
||||
pointer === null ||
|
||||
(typeof pointer === 'string' &&
|
||||
canonicalSector(pointer) &&
|
||||
pointer !== MOBILE_LOCATION),
|
||||
'The mobile stronghold needs a canonical Arrakis pointer.',
|
||||
);
|
||||
for (const [key, amount] of Object.entries(board.forces))
|
||||
requireShipment(
|
||||
canonicalSector(key) &&
|
||||
whole(amount) &&
|
||||
amount <= 20 &&
|
||||
(key !== MOBILE_LOCATION || !!pointer),
|
||||
'Guild shipment needs valid physical force locations and counts.',
|
||||
);
|
||||
for (const [key, amount] of Object.entries(board.eliteForces))
|
||||
requireShipment(
|
||||
canonicalSector(key) && whole(amount) && amount === 0,
|
||||
'Guild counters cannot acquire special force identities.',
|
||||
);
|
||||
const boardTotal = Object.values(board.forces).reduce((sum, n) => sum + n, 0);
|
||||
const worldTotal = worlds.reduce(
|
||||
(sum, w) =>
|
||||
sum +
|
||||
(w.forces[player.id]?.normal ?? 0) +
|
||||
(w.forces[player.id]?.elite ?? 0),
|
||||
0,
|
||||
);
|
||||
requireShipment(
|
||||
whole(boardTotal) && boardTotal + worldTotal <= 20,
|
||||
'Guild board and Homeworld custody exceed its twenty physical counters.',
|
||||
);
|
||||
const entries = Object.entries(intent.sources);
|
||||
requireShipment(
|
||||
entries.length > 0,
|
||||
'Choose forces from one Arrakis territory.',
|
||||
);
|
||||
let origin: string | undefined;
|
||||
let amount = 0;
|
||||
const boardForces = { ...board.forces };
|
||||
const boardEliteForces = { ...board.eliteForces };
|
||||
const boardSources: {
|
||||
key: string;
|
||||
before: HomeworldForces;
|
||||
after: HomeworldForces;
|
||||
}[] = [];
|
||||
for (const [key, selected] of entries) {
|
||||
requireShipment(
|
||||
canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer),
|
||||
'Choose a canonical, active Arrakis source sector.',
|
||||
);
|
||||
const source = splitLocation(key);
|
||||
requireShipment(source.sector !== context.storm, 'The source is in storm.');
|
||||
requireShipment(
|
||||
origin === undefined || origin === source.territory,
|
||||
'A Guild force group must come from one Arrakis territory.',
|
||||
);
|
||||
origin = source.territory;
|
||||
requireShipment(
|
||||
record(selected) &&
|
||||
Object.keys(selected).length === 2 &&
|
||||
whole(selected.normal) &&
|
||||
whole(selected.elite) &&
|
||||
selected.normal > 0 &&
|
||||
selected.elite === 0 &&
|
||||
selected.normal <= 20,
|
||||
'Each source needs a positive physical Guild counter group.',
|
||||
);
|
||||
const before = {
|
||||
normal: Object.hasOwn(board.forces, key) ? board.forces[key] : 0,
|
||||
elite: 0,
|
||||
};
|
||||
requireShipment(
|
||||
selected.normal <= before.normal,
|
||||
'The selected Arrakis sector does not hold those physical counters.',
|
||||
);
|
||||
const after = { normal: before.normal - selected.normal, elite: 0 };
|
||||
amount += selected.normal;
|
||||
if (after.normal) boardForces[key] = after.normal;
|
||||
else delete boardForces[key];
|
||||
delete boardEliteForces[key];
|
||||
boardSources.push({ key, before, after });
|
||||
}
|
||||
requireShipment(
|
||||
amount <= 20,
|
||||
'A shipment cannot exceed twenty physical counters.',
|
||||
);
|
||||
const transferred = quoteHomeworldCustody(context, custody, [
|
||||
{
|
||||
homeworld: destination.id,
|
||||
player: player.id,
|
||||
withdraw: { normal: 0, elite: 0 },
|
||||
deposit: { normal: amount, elite: 0 },
|
||||
},
|
||||
]);
|
||||
return {
|
||||
...transferred,
|
||||
amount,
|
||||
elite: 0,
|
||||
cost: Math.ceil(amount / 2),
|
||||
origin: origin!,
|
||||
boardSources,
|
||||
boardForces,
|
||||
boardEliteForces,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext } from './homeworld-custody';
|
||||
|
||||
/** E3 alliance restriction uses present foreign forces, independently of any
|
||||
* saved turn entitlement to an occupied card's benefits. */
|
||||
export function homeworldAllianceBlock(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
first: string,
|
||||
second: string,
|
||||
): string | null {
|
||||
const occupied = homeworldForceGroups(context, custody).some((world) => {
|
||||
const visitor = world.native === first ? second : world.native === second ? first : null;
|
||||
const forces = visitor ? world.forces[visitor] : undefined;
|
||||
return !!forces && forces.normal + forces.elite > 0;
|
||||
});
|
||||
return occupied ? 'An alliance cannot form while either faction has forces on the other’s Homeworld.' : null;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Game } from './engine';
|
||||
import { location, validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
|
||||
export type HomeworldAmbassadorArrival = {
|
||||
event: string;
|
||||
entrant: string;
|
||||
destination: string;
|
||||
parent?: string;
|
||||
completed: boolean;
|
||||
};
|
||||
export type HomeworldArrivalFrame = {
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
player: string;
|
||||
stage: 'waiting' | 'choice' | 'arrival' | 'complete';
|
||||
signature: string;
|
||||
destination?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
};
|
||||
export type HomeworldArrivalTag = 'revivalEvent' | 'victoryEvent';
|
||||
export type HomeworldArrivalContext = Pick<
|
||||
Game,
|
||||
'turn' | 'phase' | 'players' | 'pendingAmbassador'
|
||||
>;
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Homeworld arrival has lost its original Ambassador chain or completion proof.',
|
||||
);
|
||||
}
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
function keys(value: unknown, allowed: string[]): boolean {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).every((key) => allowed.includes(key))
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrival provenance is fixed separately from the original revival grant. */
|
||||
export function homeworldArrivalSignature(
|
||||
frame: HomeworldArrivalFrame,
|
||||
): string {
|
||||
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
|
||||
const seen = new Set<string>();
|
||||
for (const [index, entry] of frame.ambassadors.entries()) {
|
||||
if (
|
||||
!keys(entry, [
|
||||
'event',
|
||||
'entrant',
|
||||
'destination',
|
||||
'parent',
|
||||
'completed',
|
||||
]) ||
|
||||
!text(entry.event) ||
|
||||
!text(entry.entrant) ||
|
||||
!text(entry.destination) ||
|
||||
typeof entry.completed !== 'boolean' ||
|
||||
(index < frame.ambassadors.length - 1 && !entry.completed) ||
|
||||
seen.has(entry.event) ||
|
||||
(index === 0
|
||||
? entry.parent !== undefined ||
|
||||
entry.entrant !== frame.player ||
|
||||
entry.destination !== frame.destination
|
||||
: !text(entry.parent) || !seen.has(entry.parent))
|
||||
)
|
||||
fail();
|
||||
seen.add(entry.event);
|
||||
}
|
||||
if (frame.destination === 'decline' && frame.ambassadors.length !== 0) fail();
|
||||
return JSON.stringify([
|
||||
// Preserve the existing persisted signature format; frame.signature binds its source.
|
||||
'homeworldRevivalArrival',
|
||||
frame.signature,
|
||||
frame.destination,
|
||||
frame.ambassadors.map((entry) => [
|
||||
entry.event,
|
||||
entry.entrant,
|
||||
entry.destination,
|
||||
entry.parent ?? null,
|
||||
entry.completed,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Only an actual Ambassador opening may append to this linear arrival chain. */
|
||||
export function appendHomeworldArrivalAmbassador(
|
||||
frame: HomeworldArrivalFrame,
|
||||
entry: {
|
||||
event: string;
|
||||
entrant: string;
|
||||
territory: string;
|
||||
sector: number;
|
||||
turn: number;
|
||||
phase: number;
|
||||
},
|
||||
parent?: string,
|
||||
): void {
|
||||
if (
|
||||
frame.stage !== 'arrival' ||
|
||||
frame.turn !== entry.turn ||
|
||||
frame.phase !== entry.phase ||
|
||||
!validLocation(entry.territory, entry.sector) ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
(parent !== undefined && parent !== frame.ambassadors!.at(-1)?.event)
|
||||
)
|
||||
fail();
|
||||
const ambassadors = [
|
||||
...frame.ambassadors!,
|
||||
{
|
||||
event: entry.event,
|
||||
entrant: entry.entrant,
|
||||
destination: location(entry.territory, entry.sector),
|
||||
completed: false,
|
||||
...(parent === undefined ? {} : { parent }),
|
||||
},
|
||||
];
|
||||
const arrivalSignature = homeworldArrivalSignature({
|
||||
...frame,
|
||||
ambassadors,
|
||||
});
|
||||
frame.ambassadors = ambassadors;
|
||||
frame.arrivalSignature = arrivalSignature;
|
||||
}
|
||||
|
||||
/** Record actual completion before removing its live Ambassador control. */
|
||||
export function completeHomeworldArrivalAmbassador(
|
||||
frame: HomeworldArrivalFrame,
|
||||
event: string,
|
||||
): void {
|
||||
const current = frame.ambassadors?.at(-1);
|
||||
if (
|
||||
frame.stage !== 'arrival' ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
!current ||
|
||||
current.event !== event ||
|
||||
current.completed
|
||||
)
|
||||
fail();
|
||||
const ambassadors = frame.ambassadors!.map((record) =>
|
||||
record === current ? { ...record, completed: true } : record,
|
||||
);
|
||||
const arrivalSignature = homeworldArrivalSignature({
|
||||
...frame,
|
||||
ambassadors,
|
||||
});
|
||||
frame.ambassadors = ambassadors;
|
||||
frame.arrivalSignature = arrivalSignature;
|
||||
}
|
||||
|
||||
/** Validate only the completed transfer's arrival chain. Original entitlement,
|
||||
* winner/revival evidence and their independent obligations stay with callers. */
|
||||
export function validateHomeworldArrival(
|
||||
g: HomeworldArrivalContext,
|
||||
frame: HomeworldArrivalFrame,
|
||||
tag: HomeworldArrivalTag,
|
||||
): void {
|
||||
if (
|
||||
!['revivalEvent', 'victoryEvent'].includes(tag) ||
|
||||
!frame ||
|
||||
!['arrival', 'complete'].includes(frame.stage) ||
|
||||
!text(frame.event) ||
|
||||
!text(frame.signature) ||
|
||||
!Number.isSafeInteger(frame.turn) ||
|
||||
frame.turn < 1 ||
|
||||
frame.turn > g.turn ||
|
||||
!Number.isSafeInteger(frame.phase) ||
|
||||
frame.phase < 0 ||
|
||||
frame.phase > 8 ||
|
||||
(frame.stage === 'arrival' &&
|
||||
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
|
||||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
|
||||
frame.ambassadors!.some(
|
||||
(entry, index) =>
|
||||
!g.players.some(
|
||||
(seat) =>
|
||||
seat.id === entry.entrant &&
|
||||
(index === 0 || seat.faction === 'beneGesserit'),
|
||||
),
|
||||
)
|
||||
)
|
||||
fail();
|
||||
const unfinished = frame.ambassadors!.find((entry) => !entry.completed);
|
||||
const current = g.pendingAmbassador;
|
||||
const otherTag = tag === 'revivalEvent' ? 'victoryEvent' : 'revivalEvent';
|
||||
if (
|
||||
unfinished &&
|
||||
(frame.stage !== 'arrival' ||
|
||||
!current ||
|
||||
current[tag] !== frame.event ||
|
||||
current[otherTag] !== undefined ||
|
||||
current.event !== unfinished.event ||
|
||||
current.entrant !== unfinished.entrant ||
|
||||
location(current.territory, current.sector) !== unfinished.destination ||
|
||||
current.turn !== frame.turn ||
|
||||
current.phase !== frame.phase)
|
||||
)
|
||||
fail();
|
||||
if (
|
||||
current &&
|
||||
frame.ambassadors!.some(
|
||||
(entry) => entry.event === current.event && entry.completed,
|
||||
)
|
||||
)
|
||||
fail();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { HomeworldForces } from './homeworld-custody';
|
||||
|
||||
/** The location adapter supplies the current face and exact native army.
|
||||
* Occupation timing belongs to that adapter, not to the battle calculator. */
|
||||
export type HomeworldBattleRules = {
|
||||
native: string;
|
||||
card: HomeworldId;
|
||||
side: 'high' | 'low';
|
||||
nativeForces: HomeworldForces;
|
||||
};
|
||||
export type HomeworldExplosionLosses = {
|
||||
player: string;
|
||||
amount: number;
|
||||
options: HomeworldForces[];
|
||||
};
|
||||
|
||||
export function quoteHomeworldBattleRules(
|
||||
location: string,
|
||||
participants: readonly { id: string; faction: FactionId }[],
|
||||
rules: HomeworldBattleRules,
|
||||
) {
|
||||
const card = HOMEWORLD_CARDS.find((c) => c.id === rules?.card);
|
||||
const native = participants.find((p) => p.id === rules?.native);
|
||||
if (
|
||||
!card ||
|
||||
!native ||
|
||||
native.faction !== card.faction ||
|
||||
location !==
|
||||
`homeworld:${card.faction}${card.id === 'salusa_secundus' ? ':salusa' : ''}` ||
|
||||
!['high', 'low'].includes(rules.side) ||
|
||||
!rules.nativeForces ||
|
||||
typeof rules.nativeForces !== 'object' ||
|
||||
Array.isArray(rules.nativeForces) ||
|
||||
Object.keys(rules.nativeForces).length !== 2 ||
|
||||
!['normal', 'elite'].every((key) =>
|
||||
Object.hasOwn(rules.nativeForces, key),
|
||||
) ||
|
||||
!Object.values(rules.nativeForces).every(
|
||||
(n) => Number.isSafeInteger(n) && n >= 0,
|
||||
) ||
|
||||
rules.nativeForces.normal + rules.nativeForces.elite > 20
|
||||
)
|
||||
throw new Error(
|
||||
'The Homeworld battle needs its seated native, canonical card face and physical native army.',
|
||||
);
|
||||
const strength = card[rules.side].battleStrength;
|
||||
const amount = Math.min(
|
||||
strength,
|
||||
rules.nativeForces.normal + rules.nativeForces.elite,
|
||||
);
|
||||
const options: HomeworldForces[] = [];
|
||||
for (
|
||||
let elite = Math.max(0, amount - rules.nativeForces.normal);
|
||||
elite <= Math.min(amount, rules.nativeForces.elite);
|
||||
elite++
|
||||
)
|
||||
options.push({ normal: amount - elite, elite });
|
||||
return {
|
||||
native: native.id,
|
||||
strength,
|
||||
explosion: { player: native.id, amount, options },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Game } from './engine';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
type HomeworldBenefitContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds'
|
||||
>;
|
||||
export type HomeworldRevivalOpening = {
|
||||
turn: number;
|
||||
tleilaxu: { player: string; low: boolean };
|
||||
};
|
||||
export class HomeworldBenefitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HomeworldBenefitError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Current native population only. The caller owns benefit timing and all
|
||||
* ordinary eligibility; Salusa never supplies another low-population bonus. */
|
||||
export function homeworldLowBonus(
|
||||
context: HomeworldBenefitContext,
|
||||
playerId: string,
|
||||
): 0 | 1 {
|
||||
if (!context.homeworlds?.custody) return 0;
|
||||
return homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).some(
|
||||
(home) =>
|
||||
home.native === playerId &&
|
||||
home.card !== 'salusa_secundus' &&
|
||||
home.side === 'low',
|
||||
)
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** Keep the unresolved special-Karama overlap consistent in validation and
|
||||
* the public controls. This is a release boundary, not a rules exception. */
|
||||
export function homeworldRevivalKaramaBlock(
|
||||
context: HomeworldBenefitContext,
|
||||
target: string,
|
||||
): string | null {
|
||||
return homeworldLowBonus(context, target)
|
||||
? 'Special Karama against a low-population Homeworld awaits the revival-overlap ruling.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Salusa's threshold counts Sardaukar at Salusa, not all Imperial reserves.
|
||||
* Its high advantage affects the faction's Sardaukar wherever they fight. */
|
||||
export function homeworldSardaukarFreeSupport(
|
||||
context: HomeworldBenefitContext,
|
||||
playerId: string,
|
||||
): boolean {
|
||||
if (!context.advanced || !context.homeworlds?.custody) return false;
|
||||
if (
|
||||
!context.players.some((p) => p.id === playerId && p.faction === 'emperor')
|
||||
)
|
||||
return false;
|
||||
return homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).some(
|
||||
(home) =>
|
||||
home.native === playerId &&
|
||||
home.card === 'salusa_secundus' &&
|
||||
home.side === 'high',
|
||||
);
|
||||
}
|
||||
|
||||
/** Temporary release boundary: a population-changing return during combat
|
||||
* must not invalidate sealed dials/support or earlier binding answers. Use
|
||||
* public battle participation, never an opponent's hidden plan contents. */
|
||||
export function homeworldSardaukarGholaBlock(
|
||||
context: HomeworldBenefitContext & Pick<Game, 'battle'>,
|
||||
playerId: string,
|
||||
): string | null {
|
||||
if (
|
||||
!context.advanced ||
|
||||
!context.homeworlds?.custody ||
|
||||
!context.battle ||
|
||||
![context.battle.attacker, context.battle.defender].includes(playerId) ||
|
||||
!context.players.some((p) => p.id === playerId && p.faction === 'emperor')
|
||||
)
|
||||
return null;
|
||||
return homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).some(
|
||||
(home) =>
|
||||
home.native === playerId &&
|
||||
home.card === 'salusa_secundus' &&
|
||||
home.population === 1,
|
||||
)
|
||||
? 'Ghola raising Salusa to high population during the Emperor’s battle awaits its battle-support timing ruling.'
|
||||
: null;
|
||||
}
|
||||
|
||||
const validTurn = (turn: unknown): turn is number =>
|
||||
typeof turn === 'number' && Number.isSafeInteger(turn) && turn > 0;
|
||||
|
||||
/** Capture before any revival-opening effect can change native population.
|
||||
* A saved receipt owns this phase's Tleilaxu free-revival income restriction. */
|
||||
export function snapshotHomeworldRevival(
|
||||
context: HomeworldBenefitContext & Pick<Game, 'turn'>,
|
||||
): HomeworldRevivalOpening | null {
|
||||
if (!context.homeworlds?.custody) return null;
|
||||
const tleilaxu = context.players.find(
|
||||
(player) => player.faction === 'tleilaxu',
|
||||
);
|
||||
if (!tleilaxu) return null;
|
||||
if (!validTurn(context.turn))
|
||||
throw new HomeworldBenefitError(
|
||||
'The Homeworld revival opening needs a valid current turn.',
|
||||
);
|
||||
return {
|
||||
turn: context.turn,
|
||||
tleilaxu: {
|
||||
player: tleilaxu.id,
|
||||
low: homeworldLowBonus(context, tleilaxu.id) === 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** Read only the saved opening decision. Never infer a missing receipt from
|
||||
* current populations: earlier revivals may already have crossed a threshold.
|
||||
* This answers only the free-revival income gate, not Ghola purchase income. */
|
||||
export function tleilaxuHomeworldFreeIncomeBlocked(
|
||||
context: HomeworldBenefitContext &
|
||||
Pick<Game, 'turn' | 'phase'> & {
|
||||
homeworldRevival?: HomeworldRevivalOpening | null;
|
||||
},
|
||||
): boolean {
|
||||
if (context.phase !== 4 || !context.homeworlds) return false;
|
||||
const tleilaxu = context.players.filter(
|
||||
(player) => player.faction === 'tleilaxu',
|
||||
);
|
||||
if (!tleilaxu.length) return false;
|
||||
const receipt: unknown = context.homeworldRevival;
|
||||
if (
|
||||
tleilaxu.length !== 1 ||
|
||||
!validTurn(context.turn) ||
|
||||
!record(receipt) ||
|
||||
Object.keys(receipt).length !== 2 ||
|
||||
!Object.hasOwn(receipt, 'turn') ||
|
||||
!Object.hasOwn(receipt, 'tleilaxu') ||
|
||||
receipt.turn !== context.turn ||
|
||||
!record(receipt.tleilaxu) ||
|
||||
Object.keys(receipt.tleilaxu).length !== 2 ||
|
||||
!Object.hasOwn(receipt.tleilaxu, 'player') ||
|
||||
!Object.hasOwn(receipt.tleilaxu, 'low') ||
|
||||
typeof receipt.tleilaxu.player !== 'string' ||
|
||||
!receipt.tleilaxu.player.trim() ||
|
||||
receipt.tleilaxu.player !== tleilaxu[0].id ||
|
||||
typeof receipt.tleilaxu.low !== 'boolean'
|
||||
)
|
||||
throw new HomeworldBenefitError(
|
||||
'The current Tleilaxu Homeworld revival-opening receipt is missing, stale or malformed.',
|
||||
);
|
||||
return receipt.tleilaxu.low;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Card } from './cards';
|
||||
import type { Game } from './engine';
|
||||
import type { HomeworldId } from './homeworld-cards';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import {
|
||||
homeworldPopulations,
|
||||
type HomeworldPopulation,
|
||||
} from './homeworld-population';
|
||||
|
||||
export type HomeworldCardEconomyContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds'
|
||||
>;
|
||||
|
||||
/** Current native physical population. Foreign visitors and the Emperor's
|
||||
* separate Salusa allocation do not increase Kaitain's population. Occupation
|
||||
* retention and entitlement are separate lifecycle rules, not inferred here. */
|
||||
export function homeworldCardEconomyPopulation(
|
||||
context: HomeworldCardEconomyContext,
|
||||
ownerId: string,
|
||||
card: HomeworldId,
|
||||
): HomeworldPopulation | null {
|
||||
if (!context.homeworlds?.custody) return null;
|
||||
return (
|
||||
homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).find((home) => home.native === ownerId && home.card === card) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Population eligibility only; the engine owns the end-of-Bidding window. */
|
||||
export function highKaitainDiscardsAvailable(
|
||||
context: HomeworldCardEconomyContext,
|
||||
ownerId: string,
|
||||
): boolean {
|
||||
return (
|
||||
homeworldCardEconomyPopulation(context, ownerId, 'kaitain')?.side === 'high'
|
||||
);
|
||||
}
|
||||
|
||||
export type KaitainDiscardQuote = { cards: Card[]; cost: number };
|
||||
|
||||
/** Pay from spice already held, then discard these physical cards without
|
||||
* activating their effects. The caller performs payment and ordinary discard
|
||||
* reactions. An empty selection is the eligible owner's optional decline. */
|
||||
export function quoteKaitainDiscards(
|
||||
context: HomeworldCardEconomyContext,
|
||||
ownerId: string,
|
||||
selectedIds: unknown,
|
||||
): KaitainDiscardQuote {
|
||||
if (!highKaitainDiscardsAvailable(context, ownerId))
|
||||
throw new HomeworldCustodyError(
|
||||
'Only the Emperor with high-population Kaitain may pay to discard Treachery Cards.',
|
||||
);
|
||||
if (
|
||||
!Array.isArray(selectedIds) ||
|
||||
!selectedIds.every(
|
||||
(id: unknown) => typeof id === 'string' && id.length > 0,
|
||||
) ||
|
||||
new Set(selectedIds).size !== selectedIds.length
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'Choose a list of unique Treachery Card identities.',
|
||||
);
|
||||
const owner = context.players.find((player) => player.id === ownerId)!;
|
||||
const hand = owner.hand;
|
||||
if (
|
||||
!Array.isArray(hand) ||
|
||||
!hand.every(
|
||||
(card) => card && typeof card.id === 'string' && card.id.length > 0,
|
||||
) ||
|
||||
new Set(hand.map((card) => card.id)).size !== hand.length
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The Emperor hand must contain unique physical Treachery Cards.',
|
||||
);
|
||||
const cards = selectedIds.map((id: string) => {
|
||||
const card = hand.find((candidate) => candidate.id === id);
|
||||
if (!card)
|
||||
throw new HomeworldCustodyError(
|
||||
'Each selected Treachery Card must still be in the Emperor hand.',
|
||||
);
|
||||
return { ...card };
|
||||
});
|
||||
const cost = cards.length * 2;
|
||||
if (!Number.isSafeInteger(owner.spice) || owner.spice < cost)
|
||||
throw new HomeworldCustodyError(
|
||||
'Kaitain costs 2 spice per discarded card, paid from spice already held.',
|
||||
);
|
||||
return { cards, cost };
|
||||
}
|
||||
|
||||
/** Restricts both ordinary Worthless sales and duplicate-Worthless sales.
|
||||
* Non-Worthless duplicates, card trades and Advanced Karama are separate
|
||||
* actions and are not restricted by this ordinary-sale quote. */
|
||||
export function homeworldWorthlessSaleBlock(
|
||||
context: HomeworldCardEconomyContext,
|
||||
ownerId: string,
|
||||
card: Pick<Card, 'kind'>,
|
||||
): string | null {
|
||||
return card.kind === 'worthless' &&
|
||||
homeworldCardEconomyPopulation(context, ownerId, 'tupile')?.side === 'high'
|
||||
? 'High-population Tupile prevents discarding Worthless Cards for spice, including duplicate sales; Advanced Karama remains available under its own rules.'
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { FactionId } from './catalog';
|
||||
|
||||
/** Original English E3 component facts. This catalog does not enable Homeworlds
|
||||
* or execute threshold changes, payments, occupation or faction effects.
|
||||
* Physical sources and discrepancies: docs/HOMEWORLD_COMPONENT_AUDIT.md. */
|
||||
export type HomeworldId =
|
||||
| 'caladan'
|
||||
| 'giedi_prime'
|
||||
| 'southern_hemisphere'
|
||||
| 'junction'
|
||||
| 'wallach_ix'
|
||||
| 'kaitain'
|
||||
| 'salusa_secundus'
|
||||
| 'ix'
|
||||
| 'tleilax'
|
||||
| 'tupile'
|
||||
| 'richese'
|
||||
| 'ecaz'
|
||||
| 'grumman';
|
||||
export type HomeworldSide = Readonly<{
|
||||
/** Inclusive range printed on this face; Salusa's printed ranges overlap. */
|
||||
reserves: Readonly<{ min: number; max: number }>;
|
||||
/** Native dial addition and native force losses in a Lasgun/Shield explosion. */
|
||||
battleStrength: number;
|
||||
gameplay: readonly string[];
|
||||
}>;
|
||||
export type HomeworldCard = Readonly<{
|
||||
id: HomeworldId;
|
||||
name: string;
|
||||
faction: FactionId;
|
||||
reserveType: 'faction' | 'sardaukar';
|
||||
high: HomeworldSide;
|
||||
low: HomeworldSide;
|
||||
occupied: Readonly<{
|
||||
gameplay: readonly string[];
|
||||
/** One bank spice per printed icon during Spice Collection, additional to
|
||||
* any payment-sharing effect described in the occupied text. */
|
||||
spiceIcons: number;
|
||||
}>;
|
||||
}>;
|
||||
const lowBonuses = [
|
||||
'Gain one additional free revival.',
|
||||
'When collecting CHOAM Charity, gain one extra spice directly from the Spice Bank.',
|
||||
] as const;
|
||||
function card(
|
||||
id: HomeworldId,
|
||||
name: string,
|
||||
faction: FactionId,
|
||||
minimum: number,
|
||||
highStrength: number,
|
||||
lowStrength: number,
|
||||
high: string[],
|
||||
low: string[],
|
||||
occupied: string[],
|
||||
spiceIcons: number,
|
||||
): HomeworldCard {
|
||||
const sardaukar = id === 'salusa_secundus';
|
||||
const side = (
|
||||
min: number,
|
||||
max: number,
|
||||
battleStrength: number,
|
||||
gameplay: readonly string[],
|
||||
): HomeworldSide =>
|
||||
Object.freeze({
|
||||
reserves: Object.freeze({ min, max }),
|
||||
battleStrength,
|
||||
gameplay: Object.freeze([...gameplay]),
|
||||
});
|
||||
return Object.freeze({
|
||||
id,
|
||||
name,
|
||||
faction,
|
||||
reserveType: sardaukar ? 'sardaukar' : 'faction',
|
||||
high: side(minimum, sardaukar ? 5 : 20, highStrength, high),
|
||||
low: side(
|
||||
0,
|
||||
sardaukar ? 2 : minimum - 1,
|
||||
lowStrength,
|
||||
sardaukar ? low : [...low, ...lowBonuses],
|
||||
),
|
||||
occupied: Object.freeze({
|
||||
gameplay: Object.freeze([...occupied]),
|
||||
spiceIcons,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export const HOMEWORLD_CARDS: readonly HomeworldCard[] = Object.freeze([
|
||||
card(
|
||||
'caladan',
|
||||
'Caladan',
|
||||
'atreides',
|
||||
6,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'After winning a battle, you may add one reserve force to the territory or homeworld where it occurred, provided you already have a force there.',
|
||||
],
|
||||
['You cannot use your Movement advantage to inspect the next Spice Blow.'],
|
||||
['The occupier shares your Bidding advantage.'],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'giedi_prime',
|
||||
'Giedi Prime',
|
||||
'harkonnen',
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'Collect 2 spice from the Spice Bank if you collect spice from any desert territories or homeworlds during this turn’s Spice Collection.',
|
||||
],
|
||||
['No additional penalty.'],
|
||||
[
|
||||
'When you buy Treachery Cards, the occupier or their ally gains your extra free Treachery Card, provided the recipient’s hand is not full.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'southern_hemisphere',
|
||||
'Southern Hemisphere',
|
||||
'fremen',
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'When a starred Fedaykin force is revived, you may immediately place it in any territory where you already have forces.',
|
||||
],
|
||||
['No additional penalty.'],
|
||||
[
|
||||
'Give the occupier half of all spice you collect during Spice Collection, rounded down.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'junction',
|
||||
'Junction',
|
||||
'guild',
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'You may offer other factions cross-planet shipment at half or full price as part of their Shipping action, including to or from homeworlds—even their own.',
|
||||
],
|
||||
['You receive only half of shipping payments, rounded up.'],
|
||||
[
|
||||
'The occupier receives the other half of shipping payments made by other players, rounded down.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'wallach_ix',
|
||||
'Wallach IX',
|
||||
'beneGesserit',
|
||||
11,
|
||||
3,
|
||||
2,
|
||||
[
|
||||
'When another faction’s shipment lets you send spiritual advisors to Polar Sink, you may send two.',
|
||||
],
|
||||
['You cannot send spiritual advisors.'],
|
||||
['The occupier and their ally are immune to Voice.'],
|
||||
1,
|
||||
),
|
||||
card(
|
||||
'kaitain',
|
||||
'Kaitain',
|
||||
'emperor',
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
[
|
||||
'At the end of Bidding, you may pay 2 spice per card to discard Treachery Cards from your hand.',
|
||||
],
|
||||
['You receive only half of Treachery Card payments, rounded up.'],
|
||||
[
|
||||
'The occupier receives the other half of Treachery Card payments you receive, rounded down.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'salusa_secundus',
|
||||
'Salusa Secundus',
|
||||
'emperor',
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
['Your Sardaukar count at full strength without spice support.'],
|
||||
['Your Sardaukar cannot be revived for free.'],
|
||||
['Your Sardaukar lose their Sardaukar advantage.'],
|
||||
0,
|
||||
),
|
||||
card(
|
||||
'ix',
|
||||
'Ix',
|
||||
'ixians',
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
['When you pay to revive a Cyborg, you also receive two Suboids for free.'],
|
||||
['You cannot move the Hidden Mobile Stronghold.'],
|
||||
['The occupier controls your Bidding advantage.'],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'tleilax',
|
||||
'Tleilax',
|
||||
'tleilaxu',
|
||||
9,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'When taking Free Revival, you may send those forces from reserves for free to any one territory or homeworld, subject to storm and occupancy restrictions.',
|
||||
],
|
||||
[
|
||||
'If you start the Revival phase at low threshold, you receive no spice when other factions take Free Revival.',
|
||||
],
|
||||
['You cannot reveal Face Dancers against the occupier or their ally.'],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'tupile',
|
||||
'Tupile',
|
||||
'choam',
|
||||
11,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'You cannot discard Worthless Cards for spice, except through Advanced Karama.',
|
||||
],
|
||||
[
|
||||
'Once per faction, when you are on another homeworld or another faction is on Tupile, you may require that faction to disclose its spice and how many weapons or defenses it holds.',
|
||||
],
|
||||
[
|
||||
'The occupier and their ally each gain one hand slot. When they no longer occupy Tupile, discard down to the normal hand limit. CHOAM loses its low-threshold advantage.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'richese',
|
||||
'Richese',
|
||||
'richese',
|
||||
10,
|
||||
2,
|
||||
2,
|
||||
['You may ship reserve forces along with a No-Field token.'],
|
||||
['You cannot move a No-Field token.'],
|
||||
[
|
||||
'The occupier chooses which Richese Treachery Card is sold during bidding and receives half of the payment you receive, rounded down.',
|
||||
],
|
||||
1,
|
||||
),
|
||||
card(
|
||||
'ecaz',
|
||||
'Ecaz',
|
||||
'ecaz',
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'Whenever a poison weapon is discarded, collect 3 spice from the Spice Bank.',
|
||||
'Your alliance can win by jointly occupying at least one stronghold and occupying two other factions’ homeworlds.',
|
||||
],
|
||||
[
|
||||
'When you and your ally are both in a battle against another faction, your opponent chooses which of you fights.',
|
||||
],
|
||||
[
|
||||
'The occupier or their ally gains Duke Vidal and may revive him from the Tanks. This overrides the Ecaz and Moritani Duke Vidal advantage.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
card(
|
||||
'grumman',
|
||||
'Grumman',
|
||||
'moritani',
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
[
|
||||
'During Spice Collection, you may add a Terror token to a stronghold that already has one, or remove a Terror token from the board, then gain 4 spice from the Spice Bank.',
|
||||
],
|
||||
[
|
||||
'You cannot reveal a Terror token unless at least three forces enter its territory.',
|
||||
],
|
||||
[
|
||||
'You cannot reveal a Terror token when the occupier or their ally enters a territory containing one.',
|
||||
],
|
||||
2,
|
||||
),
|
||||
]);
|
||||
|
||||
/** Unknown identities remain missing rather than silently selecting a faction's
|
||||
* first card: Emperor has two distinct physical worlds. */
|
||||
export function homeworldCard(id: string): HomeworldCard | undefined {
|
||||
return HOMEWORLD_CARDS.find((definition) => definition.id === id);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Game } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type HomeworldCollectionContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds'
|
||||
>;
|
||||
|
||||
export type HomeworldCollectionSource = {
|
||||
kind: 'desert' | 'homeworld' | 'stronghold' | 'technology';
|
||||
amount: number;
|
||||
};
|
||||
|
||||
/** Source amounts are actual settled receipts, including an agreed Ecaz split,
|
||||
* not capacity, presence, expected income or a change in the held balance.
|
||||
* The engine owns event timing and the durable already-awarded receipt. */
|
||||
export function quoteGiediCollectionBonus(
|
||||
context: HomeworldCollectionContext,
|
||||
ownerId: string,
|
||||
sources: HomeworldCollectionSource[],
|
||||
alreadyAwarded: boolean,
|
||||
): { player: string; amount: 0 | 2; qualifying: number } {
|
||||
const owner = context.players.find((player) => player.id === ownerId);
|
||||
if (!owner || owner.faction !== 'harkonnen')
|
||||
throw new HomeworldCustodyError(
|
||||
'Only Harkonnen can receive the native Giedi Prime collection bonus.',
|
||||
);
|
||||
if (typeof alreadyAwarded !== 'boolean' || !Array.isArray(sources))
|
||||
throw new HomeworldCustodyError(
|
||||
'Giedi Prime needs settled collection sources and an award receipt.',
|
||||
);
|
||||
let total = 0;
|
||||
let qualifying = 0;
|
||||
for (const source of sources) {
|
||||
if (
|
||||
!source ||
|
||||
typeof source !== 'object' ||
|
||||
Array.isArray(source) ||
|
||||
!['desert', 'homeworld', 'stronghold', 'technology'].includes(
|
||||
source.kind,
|
||||
) ||
|
||||
!Number.isSafeInteger(source.amount) ||
|
||||
source.amount < 0
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'Collection sources need a known kind and a nonnegative safe amount.',
|
||||
);
|
||||
total += source.amount;
|
||||
if (!Number.isSafeInteger(total))
|
||||
throw new HomeworldCustodyError('Settled collection sources overflow.');
|
||||
if (source.kind === 'desert' || source.kind === 'homeworld')
|
||||
qualifying += source.amount;
|
||||
}
|
||||
const high = context.homeworlds?.custody
|
||||
? homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).some(
|
||||
(home) =>
|
||||
home.native === ownerId &&
|
||||
home.card === 'giedi_prime' &&
|
||||
home.side === 'high',
|
||||
)
|
||||
: false;
|
||||
return {
|
||||
player: ownerId,
|
||||
amount: high && qualifying > 0 && !alreadyAwarded ? 2 : 0,
|
||||
qualifying,
|
||||
};
|
||||
}
|
||||
|
||||
/** The caller supplies the verified original entering force count. This must
|
||||
* not be fed a concealed No-Field size to infer a public reaction window.
|
||||
* Occupation retention and No-Field composition are separate rules. */
|
||||
export function lowGrummanRevealBlock(
|
||||
context: HomeworldCollectionContext,
|
||||
ownerId: string,
|
||||
entering: number,
|
||||
): string | null {
|
||||
if (!Number.isSafeInteger(entering) || entering < 0)
|
||||
throw new HomeworldCustodyError(
|
||||
'A Terror entry requires a nonnegative safe entering force count.',
|
||||
);
|
||||
if (!context.homeworlds?.custody) return null;
|
||||
const low = homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).some(
|
||||
(home) =>
|
||||
home.native === ownerId && home.card === 'grumman' && home.side === 'low',
|
||||
);
|
||||
return low && entering < 3
|
||||
? 'Low-population Grumman prevents revealing Terror unless at least three forces enter.'
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
type HomeworldReserveSeat,
|
||||
} from './homeworld-custody';
|
||||
|
||||
export type HomeworldCombatLossPlayer = HomeworldReserveSeat & {
|
||||
tanks: number;
|
||||
eliteTanks: number;
|
||||
battleLosses: number;
|
||||
/** Arrakis counters only; foreign Homeworld armies are held in custody. */
|
||||
boardForces: HomeworldForces;
|
||||
};
|
||||
export type HomeworldCombatLossContext = Omit<
|
||||
HomeworldCustodyContext,
|
||||
'players'
|
||||
> & {
|
||||
players: readonly HomeworldCombatLossPlayer[];
|
||||
};
|
||||
export type HomeworldCombatLossRequest = {
|
||||
location: string;
|
||||
player: string;
|
||||
losses: HomeworldForces;
|
||||
};
|
||||
export class HomeworldCombatLossError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HomeworldCombatLossError';
|
||||
}
|
||||
}
|
||||
function requireLoss(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new HomeworldCombatLossError(message);
|
||||
}
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
function typed(value: unknown): asserts value is HomeworldForces {
|
||||
requireLoss(
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 2 &&
|
||||
Object.hasOwn(value, 'normal') &&
|
||||
Object.hasOwn(value, 'elite') &&
|
||||
whole((value as HomeworldForces).normal) &&
|
||||
whole((value as HomeworldForces).elite) &&
|
||||
(value as HomeworldForces).normal + (value as HomeworldForces).elite <=
|
||||
20,
|
||||
'Homeworld combat needs exact physical normal and special counter counts.',
|
||||
);
|
||||
}
|
||||
function conserved(
|
||||
context: HomeworldCombatLossContext,
|
||||
custody: HomeworldCustody,
|
||||
) {
|
||||
const groups = homeworldForceGroups(context, custody);
|
||||
for (const player of context.players) {
|
||||
typed(player.boardForces);
|
||||
requireLoss(
|
||||
whole(player.tanks) &&
|
||||
player.tanks <= 20 &&
|
||||
whole(player.eliteTanks) &&
|
||||
player.eliteTanks <= player.tanks &&
|
||||
whole(player.battleLosses),
|
||||
'Homeworld combat needs valid Tanks and battle-loss totals.',
|
||||
);
|
||||
const visitors = groups.reduce(
|
||||
(sum, home) => {
|
||||
const pool =
|
||||
home.native !== player.id && Object.hasOwn(home.forces, player.id)
|
||||
? home.forces[player.id]
|
||||
: { normal: 0, elite: 0 };
|
||||
return {
|
||||
normal: sum.normal + pool.normal,
|
||||
elite: sum.elite + pool.elite,
|
||||
};
|
||||
},
|
||||
{ normal: 0, elite: 0 },
|
||||
);
|
||||
const supply =
|
||||
player.faction === 'emperor'
|
||||
? 5
|
||||
: player.faction === 'fremen'
|
||||
? 3
|
||||
: player.faction === 'ixians'
|
||||
? 7
|
||||
: 0;
|
||||
requireLoss(
|
||||
player.reserves +
|
||||
player.tanks +
|
||||
player.boardForces.normal +
|
||||
player.boardForces.elite +
|
||||
visitors.normal +
|
||||
visitors.elite ===
|
||||
20 &&
|
||||
player.eliteReserves +
|
||||
player.eliteTanks +
|
||||
player.boardForces.elite +
|
||||
visitors.elite ===
|
||||
supply,
|
||||
'Homeworld combat must conserve all 20 physical counters and their special identities.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Detached physical settlement only. The battle resolver authorizes the exact
|
||||
* casualty choice. Zero casualties are valid; printed strength never becomes
|
||||
* a counter, and no Arrakis force keys, occupation effects or payments occur. */
|
||||
export function quoteHomeworldCombatLoss(
|
||||
context: HomeworldCombatLossContext,
|
||||
custody: HomeworldCustody,
|
||||
request: HomeworldCombatLossRequest,
|
||||
) {
|
||||
try {
|
||||
conserved(context, custody);
|
||||
requireLoss(
|
||||
!!request && typeof request === 'object' && !Array.isArray(request),
|
||||
'Choose a Homeworld combat loss.',
|
||||
);
|
||||
typed(request.losses);
|
||||
const quote = quoteHomeworldCustody(context, custody, [
|
||||
{
|
||||
homeworld: request.location,
|
||||
player: request.player,
|
||||
withdraw: request.losses,
|
||||
deposit: { normal: 0, elite: 0 },
|
||||
},
|
||||
]);
|
||||
const total = request.losses.normal + request.losses.elite;
|
||||
const players = context.players.map(
|
||||
(player, index): HomeworldCombatLossPlayer => ({
|
||||
...quote.players[index],
|
||||
tanks: player.tanks + (player.id === request.player ? total : 0),
|
||||
eliteTanks:
|
||||
player.eliteTanks +
|
||||
(player.id === request.player ? request.losses.elite : 0),
|
||||
battleLosses:
|
||||
player.battleLosses + (player.id === request.player ? total : 0),
|
||||
boardForces: { ...player.boardForces },
|
||||
}),
|
||||
);
|
||||
conserved({ advanced: context.advanced, players }, quote.state);
|
||||
const receipt = quote.receipts[0];
|
||||
return {
|
||||
custody: quote.state,
|
||||
players,
|
||||
receipt: {
|
||||
location: receipt.homeworld,
|
||||
player: receipt.player,
|
||||
before: { ...receipt.before },
|
||||
after: { ...receipt.after },
|
||||
losses: { ...request.losses },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HomeworldCustodyError)
|
||||
throw new HomeworldCombatLossError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
|
||||
import {
|
||||
homeworldForceGroups,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
type HomeworldReserveSeat,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type HomeworldCombatContext = Omit<
|
||||
HomeworldCustodyContext,
|
||||
'players'
|
||||
> & {
|
||||
players: readonly (HomeworldReserveSeat & { ally?: string | null })[];
|
||||
order: readonly string[];
|
||||
};
|
||||
export type HomeworldCombatLocation = {
|
||||
id: string;
|
||||
name: string;
|
||||
native: string;
|
||||
card: HomeworldId;
|
||||
population: number;
|
||||
side: 'high' | 'low';
|
||||
nativeBattleStrength: number;
|
||||
forces: Record<string, HomeworldForces>;
|
||||
};
|
||||
export type HomeworldCombatArmy = HomeworldForces & {
|
||||
player: string;
|
||||
native: boolean;
|
||||
nativeBattleStrength: number;
|
||||
};
|
||||
export type HomeworldCombatPair = {
|
||||
territory: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
};
|
||||
export class HomeworldCombatError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HomeworldCombatError';
|
||||
}
|
||||
}
|
||||
function requireCombat(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new HomeworldCombatError(message);
|
||||
}
|
||||
|
||||
function locations(context: HomeworldCombatContext, custody: HomeworldCustody) {
|
||||
// Custody validates seated identities and all typed pools before any lookup.
|
||||
const groups = homeworldForceGroups(context, custody);
|
||||
const seats = new Map(context.players.map((player) => [player.id, player]));
|
||||
requireCombat(
|
||||
Array.isArray(context.order) &&
|
||||
context.order.length === seats.size &&
|
||||
new Set(context.order).size === seats.size &&
|
||||
context.order.every((id) => seats.has(id)),
|
||||
'Homeworld combat needs the current complete distinct player order.',
|
||||
);
|
||||
for (const player of context.players) {
|
||||
const ally = player.ally;
|
||||
requireCombat(
|
||||
ally === undefined ||
|
||||
ally === null ||
|
||||
(typeof ally === 'string' &&
|
||||
ally !== player.id &&
|
||||
seats.has(ally) &&
|
||||
seats.get(ally)!.ally === player.id),
|
||||
'Homeworld combat needs reciprocal alliances between distinct seated players.',
|
||||
);
|
||||
}
|
||||
const populations = new Map(
|
||||
homeworldPopulations(context, custody).map((home) => [home.location, home]),
|
||||
);
|
||||
return groups.map((home): HomeworldCombatLocation => {
|
||||
const population = populations.get(home.id)!;
|
||||
const card = HOMEWORLD_CARDS.find((card) => card.id === population.card)!;
|
||||
return {
|
||||
id: home.id,
|
||||
name: card.name,
|
||||
native: home.native,
|
||||
card: population.card,
|
||||
population: population.population,
|
||||
side: population.side,
|
||||
nativeBattleStrength: population.nativeBattleStrength,
|
||||
forces: home.forces,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Canonical public location facts only. Presence does not award occupation,
|
||||
* authorize an action, change a card face, or grant an Arrakis territory rule. */
|
||||
export function homeworldCombatLocation(
|
||||
context: HomeworldCombatContext,
|
||||
custody: HomeworldCustody,
|
||||
location: string,
|
||||
): HomeworldCombatLocation {
|
||||
const home = locations(context, custody).find((home) => home.id === location);
|
||||
requireCombat(
|
||||
home,
|
||||
'Choose a canonical Homeworld belonging to a seated faction.',
|
||||
);
|
||||
return home;
|
||||
}
|
||||
|
||||
/** Exact physical pool; native strength is additional printed battle strength,
|
||||
* not a conversion of special counters. Basic retains the same typed pieces. */
|
||||
export function homeworldCombatArmy(
|
||||
context: HomeworldCombatContext,
|
||||
custody: HomeworldCustody,
|
||||
location: string,
|
||||
player: string,
|
||||
): HomeworldCombatArmy {
|
||||
const home = homeworldCombatLocation(context, custody, location);
|
||||
requireCombat(
|
||||
context.players.some((seat) => seat.id === player),
|
||||
'Choose a seated Homeworld combatant.',
|
||||
);
|
||||
const forces = Object.hasOwn(home.forces, player)
|
||||
? home.forces[player]
|
||||
: { normal: 0, elite: 0 };
|
||||
const native = player === home.native;
|
||||
return {
|
||||
player,
|
||||
normal: forces.normal,
|
||||
elite: forces.elite,
|
||||
native,
|
||||
nativeBattleStrength: native ? home.nativeBattleStrength : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ordinary attacker-order pairing over positive armies. Homeworlds have no
|
||||
* storm sectors, stronghold capacity or advisor stance. An empty native pool
|
||||
* creates no defender; two foreign armies can still require their own battle. */
|
||||
export function quoteHomeworldBattles(
|
||||
context: HomeworldCombatContext,
|
||||
custody: HomeworldCustody,
|
||||
): HomeworldCombatPair[] {
|
||||
const homes = locations(context, custody);
|
||||
const seats = new Map(context.players.map((player) => [player.id, player]));
|
||||
const rank = new Map(context.order.map((id, index) => [id, index]));
|
||||
const positive = (home: HomeworldCombatLocation, id: string) =>
|
||||
Object.hasOwn(home.forces, id) &&
|
||||
home.forces[id].normal + home.forces[id].elite > 0;
|
||||
const battles: HomeworldCombatPair[] = [];
|
||||
for (const id of context.order) {
|
||||
const player = seats.get(id)!;
|
||||
for (const home of homes) {
|
||||
if (!positive(home, id)) continue;
|
||||
for (const other of context.players) {
|
||||
if (
|
||||
rank.get(other.id)! <= rank.get(id)! ||
|
||||
other.id === player.ally ||
|
||||
!positive(home, other.id)
|
||||
)
|
||||
continue;
|
||||
battles.push({ territory: home.id, attacker: id, defender: other.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return battles;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
|
||||
/** Typed physical counters. Special identity also matters in Basic Homeworlds;
|
||||
* it does not by itself grant Advanced battle strength. */
|
||||
export type HomeworldForces = { normal: number; elite: number };
|
||||
export type HomeworldReserveSeat = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
reserves: number;
|
||||
eliteReserves: number;
|
||||
};
|
||||
export type HomeworldCustodyContext = {
|
||||
advanced: boolean;
|
||||
players: readonly HomeworldReserveSeat[];
|
||||
};
|
||||
export type HomeworldLocation = {
|
||||
id: string;
|
||||
native: string;
|
||||
secondary: boolean;
|
||||
};
|
||||
export type HomeworldCustody = {
|
||||
/** Only foreign forces: native primary counts derive from the reserve totals. */
|
||||
visitors: Record<string, Record<string, HomeworldForces>>;
|
||||
/** Salusa allocation within Emperor's totals, not additional reserve forces. */
|
||||
salusa: HomeworldForces | null;
|
||||
};
|
||||
export type HomeworldCustodyChange = {
|
||||
homeworld: string;
|
||||
player: string;
|
||||
withdraw: HomeworldForces;
|
||||
deposit: HomeworldForces;
|
||||
};
|
||||
export type HomeworldCustodyReceipt = {
|
||||
homeworld: string;
|
||||
player: string;
|
||||
before: HomeworldForces;
|
||||
after: HomeworldForces;
|
||||
};
|
||||
export class HomeworldCustodyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HomeworldCustodyError';
|
||||
}
|
||||
}
|
||||
const empty = (): HomeworldForces => ({ normal: 0, elite: 0 });
|
||||
const validFaction = (value: unknown): value is FactionId =>
|
||||
FACTIONS.some((f) => f.id === value);
|
||||
const eliteLimit = (faction: FactionId) =>
|
||||
faction === 'emperor'
|
||||
? 5
|
||||
: faction === 'fremen'
|
||||
? 3
|
||||
: faction === 'ixians'
|
||||
? 7
|
||||
: 0;
|
||||
const whole = (n: unknown): n is number =>
|
||||
Number.isSafeInteger(n) && (n as number) >= 0;
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const identifier = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
function requireCustody(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(message);
|
||||
}
|
||||
function group(value: unknown): asserts value is HomeworldForces {
|
||||
requireCustody(
|
||||
record(value) &&
|
||||
Object.keys(value).length === 2 &&
|
||||
whole(value.normal) &&
|
||||
whole(value.elite) &&
|
||||
value.normal + value.elite <= 20,
|
||||
'Homeworld forces need valid physical normal and special counter counts.',
|
||||
);
|
||||
}
|
||||
function reserveSeats(
|
||||
context: HomeworldCustodyContext,
|
||||
): HomeworldReserveSeat[] {
|
||||
requireCustody(
|
||||
record(context) &&
|
||||
typeof context.advanced === 'boolean' &&
|
||||
Array.isArray(context.players) &&
|
||||
context.players.length >= 2 &&
|
||||
context.players.length <= 6,
|
||||
'Homeworld custody needs a valid two-through-six-player configuration.',
|
||||
);
|
||||
const seats = context.players.map((p) => {
|
||||
requireCustody(
|
||||
record(p) &&
|
||||
identifier(p.id) &&
|
||||
validFaction(p.faction) &&
|
||||
whole(p.reserves) &&
|
||||
p.reserves <= 20 &&
|
||||
whole(p.eliteReserves) &&
|
||||
p.eliteReserves <= p.reserves &&
|
||||
p.eliteReserves <= eliteLimit(p.faction) &&
|
||||
(p.eliteReserves === 0 ||
|
||||
['emperor', 'fremen', 'ixians'].includes(p.faction)),
|
||||
'Homeworld reserves need valid seated identities and typed totals.',
|
||||
);
|
||||
return {
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
reserves: p.reserves,
|
||||
eliteReserves: p.eliteReserves,
|
||||
};
|
||||
});
|
||||
requireCustody(
|
||||
new Set(seats.map((p) => p.id)).size === seats.length &&
|
||||
new Set(seats.map((p) => p.faction)).size === seats.length,
|
||||
'Homeworld reserve seats and factions must be unique.',
|
||||
);
|
||||
return seats;
|
||||
}
|
||||
/** Stable software locations, deliberately outside the Arrakis sector registry. */
|
||||
export function homeworldLocations(
|
||||
context: HomeworldCustodyContext,
|
||||
): HomeworldLocation[] {
|
||||
return reserveSeats(context).flatMap((p) => [
|
||||
{ id: `homeworld:${p.faction}`, native: p.id, secondary: false },
|
||||
...(p.faction === 'emperor' && context.advanced
|
||||
? [{ id: 'homeworld:emperor:salusa', native: p.id, secondary: true }]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
function nativeForces(
|
||||
seat: HomeworldReserveSeat,
|
||||
home: HomeworldLocation,
|
||||
state: HomeworldCustody,
|
||||
): HomeworldForces {
|
||||
if (home.secondary) return { ...state.salusa! };
|
||||
const split = seat.faction === 'emperor' ? state.salusa : null;
|
||||
return {
|
||||
normal: seat.reserves - seat.eliteReserves - (split?.normal ?? 0),
|
||||
elite: seat.eliteReserves - (split?.elite ?? 0),
|
||||
};
|
||||
}
|
||||
function validate(context: HomeworldCustodyContext, state: HomeworldCustody) {
|
||||
const seats = reserveSeats(context),
|
||||
homes = homeworldLocations(context);
|
||||
requireCustody(
|
||||
record(state) && Object.keys(state).length === 2 && record(state.visitors),
|
||||
'The saved Homeworld custody record is invalid.',
|
||||
);
|
||||
const emperor = seats.find((p) => p.faction === 'emperor');
|
||||
if (context.advanced && emperor) {
|
||||
group(state.salusa);
|
||||
requireCustody(
|
||||
state.salusa.normal <= emperor.reserves - emperor.eliteReserves &&
|
||||
state.salusa.elite <= emperor.eliteReserves,
|
||||
'Salusa forces must remain within Emperor’s current typed reserve totals.',
|
||||
);
|
||||
} else
|
||||
requireCustody(
|
||||
state.salusa === null,
|
||||
'Salusa is available only to an Advanced Emperor.',
|
||||
);
|
||||
const homeById = new Map(homes.map((h) => [h.id, h]));
|
||||
const byId = new Map(seats.map((p) => [p.id, p]));
|
||||
const away = new Map(seats.map((p) => [p.id, 0]));
|
||||
const awayElites = new Map(seats.map((p) => [p.id, 0]));
|
||||
for (const [id, visitors] of Object.entries(state.visitors)) {
|
||||
const home = homeById.get(id);
|
||||
requireCustody(
|
||||
home && record(visitors),
|
||||
'Foreign forces require an active Homeworld location.',
|
||||
);
|
||||
for (const [player, forces] of Object.entries(visitors)) {
|
||||
requireCustody(
|
||||
byId.has(player) && player !== home.native,
|
||||
'Native forces belong to their reserve totals, not a foreign-force pool.',
|
||||
);
|
||||
group(forces);
|
||||
away.set(player, away.get(player)! + forces.normal + forces.elite);
|
||||
awayElites.set(player, awayElites.get(player)! + forces.elite);
|
||||
}
|
||||
}
|
||||
for (const p of seats)
|
||||
requireCustody(
|
||||
p.reserves + away.get(p.id)! <= 20 &&
|
||||
p.eliteReserves + awayElites.get(p.id)! <= eliteLimit(p.faction),
|
||||
'Native reserves and foreign Homeworld forces exceed physical counter custody.',
|
||||
);
|
||||
return { seats, homes, homeById, byId };
|
||||
}
|
||||
/** Setup boundary only: all initial Sardaukar go to Salusa in Advanced play. */
|
||||
export function createHomeworldCustody(
|
||||
context: HomeworldCustodyContext,
|
||||
): HomeworldCustody {
|
||||
const seats = reserveSeats(context),
|
||||
emperor = seats.find((p) => p.faction === 'emperor');
|
||||
return {
|
||||
visitors: {},
|
||||
salusa:
|
||||
context.advanced && emperor
|
||||
? { normal: 0, elite: emperor.eliteReserves }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
export function homeworldForceGroups(
|
||||
context: HomeworldCustodyContext,
|
||||
state: HomeworldCustody,
|
||||
) {
|
||||
const { homes, byId } = validate(context, state);
|
||||
return homes.map((home) => ({
|
||||
...home,
|
||||
forces: Object.fromEntries([
|
||||
[home.native, nativeForces(byId.get(home.native)!, home, state)],
|
||||
...Object.entries(state.visitors[home.id] ?? {}).map(([id, forces]) => [
|
||||
id,
|
||||
{ ...forces },
|
||||
]),
|
||||
]) as Record<string, HomeworldForces>,
|
||||
}));
|
||||
}
|
||||
/** Custody transaction only. Callers must separately authorize shipment, revival,
|
||||
* movement or casualties and account for the matching planet/Tanks deltas.
|
||||
* Withdrawals use the source's pre-transaction pool; no silent Salusa clamping,
|
||||
* replacement type, threshold effect, occupation grant, payment or RNG occurs. */
|
||||
export function quoteHomeworldCustody(
|
||||
context: HomeworldCustodyContext,
|
||||
state: HomeworldCustody,
|
||||
changes: readonly HomeworldCustodyChange[],
|
||||
) {
|
||||
const { seats, homeById, byId } = validate(context, state);
|
||||
requireCustody(
|
||||
Array.isArray(changes),
|
||||
'Homeworld custody changes must be a list.',
|
||||
);
|
||||
const totals = new Map<string, HomeworldCustodyChange>();
|
||||
for (const change of changes) {
|
||||
requireCustody(
|
||||
record(change) &&
|
||||
identifier(change.homeworld) &&
|
||||
identifier(change.player) &&
|
||||
homeById.has(change.homeworld) &&
|
||||
byId.has(change.player),
|
||||
'Choose an active Homeworld and a seated faction.',
|
||||
);
|
||||
group(change.withdraw);
|
||||
group(change.deposit);
|
||||
const key = JSON.stringify([change.homeworld, change.player]);
|
||||
const total = totals.get(key) ?? {
|
||||
homeworld: change.homeworld,
|
||||
player: change.player,
|
||||
withdraw: empty(),
|
||||
deposit: empty(),
|
||||
};
|
||||
for (const kind of ['normal', 'elite'] as const) {
|
||||
total.withdraw[kind] += change.withdraw[kind];
|
||||
total.deposit[kind] += change.deposit[kind];
|
||||
}
|
||||
group(total.withdraw);
|
||||
group(total.deposit);
|
||||
totals.set(key, total);
|
||||
}
|
||||
const next: HomeworldCustody = {
|
||||
salusa: state.salusa ? { ...state.salusa } : null,
|
||||
visitors: Object.fromEntries(
|
||||
Object.entries(state.visitors).map(([id, pools]) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
Object.entries(pools).map(([player, forces]) => [
|
||||
player,
|
||||
{ ...forces },
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
};
|
||||
const players = seats.map((p) => ({ ...p })),
|
||||
updates = new Map(players.map((p) => [p.id, p]));
|
||||
const receipts: HomeworldCustodyReceipt[] = [];
|
||||
for (const change of totals.values()) {
|
||||
const home = homeById.get(change.homeworld)!;
|
||||
const native = home.native === change.player;
|
||||
const before = native
|
||||
? nativeForces(byId.get(change.player)!, home, state)
|
||||
: Object.hasOwn(state.visitors[home.id] ?? {}, change.player)
|
||||
? state.visitors[home.id][change.player]
|
||||
: empty();
|
||||
requireCustody(
|
||||
change.withdraw.normal <= before.normal &&
|
||||
change.withdraw.elite <= before.elite,
|
||||
'The selected Homeworld does not hold those physical counters.',
|
||||
);
|
||||
const after = {
|
||||
normal: before.normal - change.withdraw.normal + change.deposit.normal,
|
||||
elite: before.elite - change.withdraw.elite + change.deposit.elite,
|
||||
};
|
||||
group(after);
|
||||
if (native) {
|
||||
const player = updates.get(change.player)!;
|
||||
player.reserves +=
|
||||
after.normal + after.elite - before.normal - before.elite;
|
||||
player.eliteReserves += after.elite - before.elite;
|
||||
if (home.secondary) next.salusa = after;
|
||||
} else if (after.normal + after.elite) {
|
||||
next.visitors[home.id] = {
|
||||
...next.visitors[home.id],
|
||||
[change.player]: after,
|
||||
};
|
||||
} else {
|
||||
delete next.visitors[home.id]?.[change.player];
|
||||
if (next.visitors[home.id] && !Object.keys(next.visitors[home.id]).length)
|
||||
delete next.visitors[home.id];
|
||||
}
|
||||
receipts.push({
|
||||
homeworld: home.id,
|
||||
player: change.player,
|
||||
before: { ...before },
|
||||
after: { ...after },
|
||||
});
|
||||
}
|
||||
validate({ advanced: context.advanced, players }, next);
|
||||
return { players, state: next, receipts };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type EmperorHomeworld = 'homeworld:emperor' | 'homeworld:emperor:salusa';
|
||||
export type EmperorHomeworldMoveContext = HomeworldCustodyContext & {
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished';
|
||||
phase: number;
|
||||
currentPlayer: string | null;
|
||||
/** Remaining complete movements, including a legally granted Hajr movement.
|
||||
* The caller owns control/overlay eligibility and the authoritative budget. */
|
||||
movesLeft: number;
|
||||
};
|
||||
export type EmperorHomeworldMove = {
|
||||
origin: EmperorHomeworld;
|
||||
forces: HomeworldForces;
|
||||
};
|
||||
export class EmperorHomeworldMoveError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'EmperorHomeworldMoveError';
|
||||
}
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
function requireMove(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new EmperorHomeworldMoveError(message);
|
||||
}
|
||||
|
||||
/** E3 p.10 permits this transfer as Emperor's movement. It is Homeworld module
|
||||
* permission, immune to Karama; no ordinary faction-cancellation flag is read.
|
||||
* Hajr obeys own-turn movement timing (November 2020 FAQ p.8) and supplies an
|
||||
* additional movement, not a separate once-per-turn transfer entitlement.
|
||||
*
|
||||
* This quote commits nothing: no shipment, charge, phase/queue change, arrival
|
||||
* effect, occupation entitlement or random event is produced. */
|
||||
export function quoteEmperorHomeworldMove(
|
||||
context: EmperorHomeworldMoveContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
order: EmperorHomeworldMove,
|
||||
) {
|
||||
requireMove(
|
||||
record(context) &&
|
||||
context.status === 'playing' &&
|
||||
context.advanced === true &&
|
||||
context.phase === 5 &&
|
||||
typeof actor === 'string' &&
|
||||
actor.trim().length > 0 &&
|
||||
context.currentPlayer === actor &&
|
||||
whole(context.movesLeft) &&
|
||||
context.movesLeft > 0,
|
||||
'Emperor Homeworld movement requires an unused movement during the active Advanced Shipment and Movement turn.',
|
||||
);
|
||||
requireMove(
|
||||
record(order) &&
|
||||
Object.keys(order).length === 2 &&
|
||||
(order.origin === 'homeworld:emperor' ||
|
||||
order.origin === 'homeworld:emperor:salusa') &&
|
||||
record(order.forces) &&
|
||||
Object.keys(order.forces).length === 2 &&
|
||||
whole(order.forces.normal) &&
|
||||
whole(order.forces.elite) &&
|
||||
Number.isSafeInteger(order.forces.normal + order.forces.elite) &&
|
||||
order.forces.normal + order.forces.elite > 0 &&
|
||||
order.forces.normal + order.forces.elite <= 20,
|
||||
'Choose a positive typed force group from Kaitain or Salusa Secundus.',
|
||||
);
|
||||
try {
|
||||
// Shared projection validates the entire public physical roster and custody.
|
||||
const before = homeworldPopulations(context, custody);
|
||||
const emperor = context.players.find((p) => p.id === actor);
|
||||
requireMove(
|
||||
emperor?.faction === 'emperor',
|
||||
'Only the seated Emperor can move between its Homeworlds.',
|
||||
);
|
||||
const origin: EmperorHomeworld = order.origin;
|
||||
const destination: EmperorHomeworld =
|
||||
origin === 'homeworld:emperor'
|
||||
? 'homeworld:emperor:salusa'
|
||||
: 'homeworld:emperor';
|
||||
const forces = { normal: order.forces.normal, elite: order.forces.elite };
|
||||
const transferred = quoteHomeworldCustody(context, custody, [
|
||||
{
|
||||
homeworld: origin,
|
||||
player: actor,
|
||||
withdraw: forces,
|
||||
deposit: { normal: 0, elite: 0 },
|
||||
},
|
||||
{
|
||||
homeworld: destination,
|
||||
player: actor,
|
||||
withdraw: { normal: 0, elite: 0 },
|
||||
deposit: forces,
|
||||
},
|
||||
]);
|
||||
// A move redistributes the existing native reserve total. It cannot create
|
||||
// an additional native pool or consume somebody else's visitor counters.
|
||||
requireMove(
|
||||
transferred.players.every((seat) => {
|
||||
const original = context.players.find((p) => p.id === seat.id)!;
|
||||
return (
|
||||
seat.reserves === original.reserves &&
|
||||
seat.eliteReserves === original.eliteReserves
|
||||
);
|
||||
}),
|
||||
'Emperor Homeworld movement must preserve every native reserve total.',
|
||||
);
|
||||
const after = homeworldPopulations(
|
||||
{ advanced: context.advanced, players: transferred.players },
|
||||
transferred.state,
|
||||
);
|
||||
return {
|
||||
player: actor,
|
||||
origin,
|
||||
destination,
|
||||
forces,
|
||||
movementSpent: 1 as const,
|
||||
movesLeft: context.movesLeft - 1,
|
||||
players: transferred.players,
|
||||
state: transferred.state,
|
||||
receipts: transferred.receipts,
|
||||
populations: { before, after },
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof HomeworldCustodyError)
|
||||
throw new EmperorHomeworldMoveError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Game } from './engine';
|
||||
import {
|
||||
homeworldForceGroups,
|
||||
HomeworldCustodyError,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldAllianceBlock } from './homeworld-alliance';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
|
||||
import { charityQuote } from './charity';
|
||||
|
||||
export function homeworldContext(g: Pick<Game, 'advanced' | 'players'>) {
|
||||
return {
|
||||
advanced: g.advanced,
|
||||
players: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
reserves: p.reserves,
|
||||
eliteReserves: p.elites?.reserves ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Native/visitor custody is physical state. Reading or normalizing never
|
||||
* reconstructs a missing Salusa allocation from present aggregate reserves. */
|
||||
export function homeworldGameIntegrity(g: Game): void {
|
||||
const state = g.homeworlds;
|
||||
if (state === undefined || state === null) return;
|
||||
if (
|
||||
typeof state !== 'object' ||
|
||||
Array.isArray(state) ||
|
||||
Object.keys(state).length !== 1 ||
|
||||
!Object.hasOwn(state, 'custody')
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Homeworld module record is invalid.',
|
||||
);
|
||||
const beforeForces =
|
||||
g.status === 'lobby' ||
|
||||
(g.status === 'setup' &&
|
||||
(g.setupStage === 'prediction' || g.setupStage === 'traitors'));
|
||||
if (beforeForces) {
|
||||
if (state.custody !== null)
|
||||
throw new HomeworldCustodyError(
|
||||
'Homeworld custody starts with force placement, after prediction and traitors.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!state.custody)
|
||||
throw new HomeworldCustodyError(
|
||||
'This game is missing its saved Homeworld force locations.',
|
||||
);
|
||||
if (g.status === 'playing' && g.phase === 4)
|
||||
tleilaxuHomeworldFreeIncomeBlocked(g);
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const savedControls: (
|
||||
| { response?: Game['response']; pendingKarama?: Game['pendingKarama'] }
|
||||
| null
|
||||
| undefined
|
||||
)[] = [
|
||||
g,
|
||||
g.pendingExchange,
|
||||
g.pendingNullentropy?.resume,
|
||||
g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume,
|
||||
g.summonedWorm?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null,
|
||||
];
|
||||
for (const controls of savedControls) {
|
||||
const conversion = controls?.pendingKarama;
|
||||
const response =
|
||||
controls?.response?.kind === 'worthlessKarama' &&
|
||||
conversion?.use.kind === 'cancel'
|
||||
? conversion.use.response
|
||||
: controls?.response;
|
||||
if (response?.kind !== 'bgCharity') continue;
|
||||
const owner = g.players.find((p) => p.id === response.owner);
|
||||
const quote = owner ? charityQuote(g, owner) : null;
|
||||
if (
|
||||
!owner ||
|
||||
g.phase !== 2 ||
|
||||
!g.advanced ||
|
||||
owner.faction !== 'beneGesserit' ||
|
||||
owner.charityTurn !== g.turn ||
|
||||
!quote?.total ||
|
||||
response.amount !== quote.total ||
|
||||
response.charityHomeworld !== quote.homeworld
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Homeworld charity claim does not match its eligible ordinary and bank amounts.',
|
||||
);
|
||||
}
|
||||
const groups = homeworldForceGroups(homeworldContext(g), state.custody);
|
||||
for (const p of g.players) {
|
||||
if (p.ally && g.players.some((other) => other.id === p.ally && other.ally === p.id)) {
|
||||
const blocked = homeworldAllianceBlock(homeworldContext(g), state.custody, p.id, p.ally);
|
||||
if (blocked) throw new HomeworldCustodyError(blocked);
|
||||
}
|
||||
let away = 0,
|
||||
awayElite = 0;
|
||||
for (const home of groups) {
|
||||
if (home.native === p.id || !Object.hasOwn(home.forces, p.id)) continue;
|
||||
const force = home.forces[p.id];
|
||||
away += force.normal + force.elite;
|
||||
awayElite += force.elite;
|
||||
}
|
||||
const counts = [p.reserves, p.tanks, ...Object.values(p.forces)];
|
||||
if (
|
||||
counts.some((n) => !Number.isSafeInteger(n) || n < 0) ||
|
||||
counts.reduce((a, b) => a + b, away) !== 20
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'Forces on Arrakis, Homeworlds and in the Tanks must conserve twenty physical counters.',
|
||||
);
|
||||
const expected =
|
||||
p.faction === 'ixians'
|
||||
? 7
|
||||
: p.faction === 'fremen'
|
||||
? 3
|
||||
: p.faction === 'emperor'
|
||||
? 5
|
||||
: 0;
|
||||
const special = p.elites;
|
||||
if (expected) {
|
||||
if (
|
||||
!special ||
|
||||
[
|
||||
special.reserves,
|
||||
special.tanks,
|
||||
...Object.values(special.forces),
|
||||
].some((n) => !Number.isSafeInteger(n) || n < 0) ||
|
||||
special.reserves +
|
||||
special.tanks +
|
||||
Object.values(special.forces).reduce((a, b) => a + b, awayElite) !==
|
||||
expected ||
|
||||
special.tanks > p.tanks ||
|
||||
Object.entries(special.forces).some(
|
||||
([key, n]) => n > (p.forces[key] ?? 0),
|
||||
)
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'Homeworld games preserve the faction’s complete physical special-counter inventory.',
|
||||
);
|
||||
} else if (special)
|
||||
throw new HomeworldCustodyError(
|
||||
'This faction has no physical special-force counters.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function homeworldTable(g: Game) {
|
||||
homeworldGameIntegrity(g);
|
||||
if (!g.homeworlds?.custody) return null;
|
||||
const context = homeworldContext(g);
|
||||
const populations = homeworldPopulations(context, g.homeworlds.custody);
|
||||
return homeworldForceGroups(context, g.homeworlds.custody).map((home) => ({
|
||||
...home,
|
||||
...populations.find((p) => p.location === home.id)!,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Game } from './engine';
|
||||
import type { HomeworldId } from './homeworld-cards';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type HomeworldMobilityContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds'
|
||||
>;
|
||||
|
||||
/** Current physical native population, shared by validation, controls and AI.
|
||||
* Occupation retention is a separate unresolved lifecycle; this projection
|
||||
* does not infer an occupier or award any occupation advantage. */
|
||||
function populationSide(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
card: HomeworldId,
|
||||
): 'high' | 'low' | null {
|
||||
if (!context.homeworlds?.custody) return null;
|
||||
return (
|
||||
homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).find((home) => home.native === playerId && home.card === card)?.side ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the Atreides Movement advantage: other legal card/auction knowledge
|
||||
* is unaffected. Callers still own the ordinary faction and timing checks. */
|
||||
export function homeworldMovementForesightBlock(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
): string | null {
|
||||
return populationSide(context, playerId, 'caladan') === 'low'
|
||||
? 'Low-population Caladan prevents the Atreides Movement advantage from inspecting the next Spice Blow.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Relocation of the stronghold, including special Karama relocation. Troops
|
||||
* may still move through a stationary Hidden Mobile Stronghold normally. */
|
||||
export function homeworldMobileStrongholdMovementBlock(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
): string | null {
|
||||
return populationSide(context, playerId, 'ix') === 'low'
|
||||
? 'Low-population Ix prevents movement of the Hidden Mobile Stronghold.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** An existing No-Field marker's movement, not its shipment or revelation. */
|
||||
export function homeworldNoFieldMovementBlock(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
): string | null {
|
||||
return populationSide(context, playerId, 'richese') === 'low'
|
||||
? 'Low-population Richese prevents movement of a No-Field token.'
|
||||
: null;
|
||||
}
|
||||
|
||||
export type HomeworldSpiritualAdvisorQuote = {
|
||||
maximum: number;
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
/** Printed population allowance without requiring an available placement
|
||||
* resource. Cancellation validates the denied action without deploying forces;
|
||||
* ordinary games can still cancel a saved advisor after reserves disappear. */
|
||||
export function homeworldSpiritualAdvisorAllowance(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
destination: string,
|
||||
): number {
|
||||
if (
|
||||
!context.players.some(
|
||||
(p) => p.id === playerId && p.faction === 'beneGesserit',
|
||||
)
|
||||
)
|
||||
return 0;
|
||||
const side = populationSide(context, playerId, 'wallach_ix');
|
||||
return side === 'low'
|
||||
? 0
|
||||
: side === 'high' && destination === 'polar_sink'
|
||||
? 2
|
||||
: 1;
|
||||
}
|
||||
|
||||
/** Free accompaniment to another faction's shipment only. Ordinary shipment
|
||||
* eligibility, destination legality and Karama windows remain caller-owned;
|
||||
* this never restricts paid BG shipments, movement or advisor stance changes.
|
||||
* Two advisors is an optional high-Wallach choice only at Polar Sink. */
|
||||
export function homeworldSpiritualAdvisorQuote(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
destination: string,
|
||||
): HomeworldSpiritualAdvisorQuote {
|
||||
const player = context.players.find(
|
||||
(candidate) =>
|
||||
candidate.id === playerId && candidate.faction === 'beneGesserit',
|
||||
);
|
||||
if (!player)
|
||||
return {
|
||||
maximum: 0,
|
||||
blocked: 'Only Bene Gesserit can send spiritual advisors.',
|
||||
};
|
||||
const allowance = homeworldSpiritualAdvisorAllowance(
|
||||
context,
|
||||
playerId,
|
||||
destination,
|
||||
);
|
||||
if (allowance === 0)
|
||||
return {
|
||||
maximum: 0,
|
||||
blocked: 'Low-population Wallach IX prevents sending spiritual advisors.',
|
||||
};
|
||||
if (player.reserves === 0)
|
||||
return {
|
||||
maximum: 0,
|
||||
blocked: 'No reserve force is available to send as a spiritual advisor.',
|
||||
};
|
||||
return {
|
||||
maximum: Math.min(player.reserves, allowance),
|
||||
blocked: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function homeworldSpiritualAdvisorLimit(
|
||||
context: HomeworldMobilityContext,
|
||||
playerId: string,
|
||||
destination: string,
|
||||
): number {
|
||||
return homeworldSpiritualAdvisorQuote(context, playerId, destination).maximum;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
type HomeworldCustodyChange,
|
||||
type HomeworldForces,
|
||||
} from './homeworld-custody';
|
||||
|
||||
export type NativeReserveSelections = Readonly<Record<string, HomeworldForces>>;
|
||||
export class NativeReserveError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NativeReserveError';
|
||||
}
|
||||
}
|
||||
const empty = (): HomeworldForces => ({ normal: 0, elite: 0 });
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
function requireReserve(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new NativeReserveError(message);
|
||||
}
|
||||
function typed(value: unknown): asserts value is HomeworldForces {
|
||||
requireReserve(
|
||||
record(value) &&
|
||||
Object.keys(value).length === 2 &&
|
||||
Object.hasOwn(value, 'normal') &&
|
||||
Object.hasOwn(value, 'elite') &&
|
||||
typeof value.normal === 'number' &&
|
||||
Number.isSafeInteger(value.normal) &&
|
||||
value.normal >= 0 &&
|
||||
typeof value.elite === 'number' &&
|
||||
Number.isSafeInteger(value.elite) &&
|
||||
value.elite >= 0 &&
|
||||
Number.isSafeInteger(value.normal + value.elite) &&
|
||||
value.normal + value.elite <= 20,
|
||||
'Choose valid physical normal and special reserve counts.',
|
||||
);
|
||||
}
|
||||
function checked<T>(fn: () => T): T {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error) {
|
||||
if (error instanceof HomeworldCustodyError)
|
||||
throw new NativeReserveError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function nativeHomes(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
) {
|
||||
const homes = homeworldForceGroups(context, custody).filter(
|
||||
(home) => home.native === actor,
|
||||
);
|
||||
requireReserve(
|
||||
typeof actor === 'string' && actor.trim().length > 0 && homes.length > 0,
|
||||
'Choose a seated native reserve owner.',
|
||||
);
|
||||
return homes;
|
||||
}
|
||||
|
||||
/** Removes native reserves for a separately authorized external route. Neither
|
||||
* visitor forces nor a guessed Emperor split can fund a withdrawal. Zero is a
|
||||
* physical no-op, useful for zero-force callers; action permission is external. */
|
||||
export function quoteNativeReserveWithdrawal(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
requested: HomeworldForces,
|
||||
selections?: NativeReserveSelections,
|
||||
) {
|
||||
return checked(() => {
|
||||
const homes = nativeHomes(context, custody, actor);
|
||||
typed(requested);
|
||||
const selected = new Map<string, HomeworldForces>();
|
||||
if (selections !== undefined) {
|
||||
requireReserve(
|
||||
record(selections),
|
||||
'Choose a valid Homeworld source allocation.',
|
||||
);
|
||||
const sum = empty();
|
||||
for (const [location, forces] of Object.entries(selections)) {
|
||||
requireReserve(
|
||||
homes.some((home) => home.id === location),
|
||||
'Reserve sources must be the selected faction’s native Homeworlds.',
|
||||
);
|
||||
typed(forces);
|
||||
selected.set(location, { normal: forces.normal, elite: forces.elite });
|
||||
sum.normal += forces.normal;
|
||||
sum.elite += forces.elite;
|
||||
}
|
||||
requireReserve(
|
||||
sum.normal === requested.normal && sum.elite === requested.elite,
|
||||
'The chosen Homeworld sources must exactly match both requested force types.',
|
||||
);
|
||||
} else {
|
||||
for (const home of homes) selected.set(home.id, empty());
|
||||
for (const kind of ['normal', 'elite'] as const) {
|
||||
const total = homes.reduce(
|
||||
(sum, home) => sum + home.forces[actor][kind],
|
||||
0,
|
||||
);
|
||||
requireReserve(
|
||||
requested[kind] <= total,
|
||||
'The native Homeworlds do not hold the requested reserve counters.',
|
||||
);
|
||||
for (const home of homes) {
|
||||
const available = home.forces[actor][kind];
|
||||
const least = Math.max(0, requested[kind] - (total - available));
|
||||
const most = Math.min(requested[kind], available);
|
||||
requireReserve(
|
||||
least === most,
|
||||
`Choose the Homeworld sources for the ${kind === 'elite' ? 'special' : 'normal'} reserves; more than one allocation is possible.`,
|
||||
);
|
||||
selected.get(home.id)![kind] = least;
|
||||
}
|
||||
}
|
||||
}
|
||||
const changes: HomeworldCustodyChange[] = homes.flatMap((home) => {
|
||||
const forces = selected.get(home.id) ?? empty();
|
||||
return forces.normal + forces.elite > 0
|
||||
? [
|
||||
{
|
||||
homeworld: home.id,
|
||||
player: actor,
|
||||
withdraw: forces,
|
||||
deposit: empty(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
return quoteHomeworldCustody(context, custody, changes);
|
||||
});
|
||||
}
|
||||
|
||||
/** Revival destination only: E3 p.10 restores Advanced Emperor's normal forces
|
||||
* to Kaitain and Sardaukar to Salusa. Other native reserve returns must use their
|
||||
* own route contract; this does not validate Tanks, cost or revival allowances. */
|
||||
export function quoteNativeRevivalDeposit(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
forces: HomeworldForces,
|
||||
) {
|
||||
return checked(() => {
|
||||
const homes = nativeHomes(context, custody, actor);
|
||||
typed(forces);
|
||||
const primary = homes.find((home) => !home.secondary)!;
|
||||
const secondary = homes.find((home) => home.secondary);
|
||||
const placements: [string, HomeworldForces][] = secondary
|
||||
? [
|
||||
[primary.id, { normal: forces.normal, elite: 0 }],
|
||||
[secondary.id, { normal: 0, elite: forces.elite }],
|
||||
]
|
||||
: [[primary.id, { normal: forces.normal, elite: forces.elite }]];
|
||||
return quoteHomeworldCustody(
|
||||
context,
|
||||
custody,
|
||||
placements.flatMap(([homeworld, deposit]) =>
|
||||
deposit.normal + deposit.elite > 0
|
||||
? [{ homeworld, player: actor, withdraw: empty(), deposit }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import {
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
homeworldForceGroups,
|
||||
HomeworldCustodyError,
|
||||
} from './homeworld-custody';
|
||||
import {
|
||||
EmperorHomeworldMoveError,
|
||||
quoteEmperorHomeworldMove,
|
||||
type EmperorHomeworldMove,
|
||||
} from './homeworld-emperor-move';
|
||||
import {
|
||||
NativeReserveError,
|
||||
quoteNativeReserveWithdrawal,
|
||||
type NativeReserveSelections,
|
||||
} from './homeworld-native-reserves';
|
||||
|
||||
/** Reconstitute only the public physical custody accepted by the shared quotes.
|
||||
* Private hands, plans, reserve decks and random state are never consulted. */
|
||||
function publicCustody(view: GameView) {
|
||||
const worlds = view.homeworlds?.worlds;
|
||||
if (!worlds?.length) return null;
|
||||
const context = {
|
||||
advanced: view.advanced,
|
||||
players: view.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
reserves: p.reserves,
|
||||
eliteReserves: p.elites?.reserves ?? 0,
|
||||
})),
|
||||
};
|
||||
const custody: HomeworldCustody = { salusa: null, visitors: {} };
|
||||
for (const home of worlds) {
|
||||
if (home.secondary) custody.salusa = { ...home.forces[home.native] };
|
||||
const visitors = Object.entries(home.forces).filter(
|
||||
([owner]) => owner !== home.native,
|
||||
);
|
||||
if (visitors.length)
|
||||
custody.visitors[home.id] = Object.fromEntries(
|
||||
visitors.map(([owner, forces]) => [owner, { ...forces }]),
|
||||
);
|
||||
}
|
||||
return { context, custody, worlds };
|
||||
}
|
||||
|
||||
/** A deterministic explicit source choice for an ordinary native shipment.
|
||||
* Amount includes elites. Null means no applicable/valid allocation; zero is
|
||||
* valid physical custody even when the shipment route has other requirements. */
|
||||
export function nativeShipmentSources(
|
||||
view: GameView,
|
||||
amount: number,
|
||||
elite = 0,
|
||||
): NativeReserveSelections | null {
|
||||
const physical = publicCustody(view);
|
||||
if (!physical) return null;
|
||||
if (
|
||||
!Number.isSafeInteger(amount) ||
|
||||
amount < 0 ||
|
||||
amount > 20 ||
|
||||
!Number.isSafeInteger(elite) ||
|
||||
elite < 0 ||
|
||||
elite > amount
|
||||
)
|
||||
return null;
|
||||
return nativeReserveSources(physical.context, physical.custody, view.me, {
|
||||
normal: amount - elite,
|
||||
elite,
|
||||
});
|
||||
}
|
||||
|
||||
/** Shared deterministic custody choice for public clients and server-generated
|
||||
* shipment witnesses. This has no viewGame dependency or action permission. */
|
||||
export function nativeReserveSources(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
actor: string,
|
||||
requested: HomeworldForces,
|
||||
): NativeReserveSelections | null {
|
||||
try {
|
||||
const remaining = { ...requested };
|
||||
const sources: Record<string, { normal: number; elite: number }> = {};
|
||||
const homes = homeworldForceGroups(context, custody)
|
||||
.filter((home) => home.native === actor)
|
||||
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
||||
for (const home of homes) {
|
||||
const available = home.forces[actor];
|
||||
sources[home.id] = {
|
||||
normal: Math.min(remaining.normal, available.normal),
|
||||
elite: Math.min(remaining.elite, available.elite),
|
||||
};
|
||||
remaining.normal -= sources[home.id].normal;
|
||||
remaining.elite -= sources[home.id].elite;
|
||||
}
|
||||
quoteNativeReserveWithdrawal(context, custody, actor, requested, sources);
|
||||
return sources;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NativeReserveError ||
|
||||
error instanceof HomeworldCustodyError
|
||||
)
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve human source choices; add a source choice for generated ordinary
|
||||
* shipments. No-Fields and disabled modules retain their own route semantics. */
|
||||
export function withNativeShipmentSources(
|
||||
view: GameView,
|
||||
action: Action,
|
||||
): Action | null {
|
||||
if (
|
||||
action.type !== 'ship' ||
|
||||
action.noField !== undefined ||
|
||||
!view.homeworlds
|
||||
)
|
||||
return action;
|
||||
if (typeof action.amount !== 'number') return null;
|
||||
const me = view.players.find((p) => p.id === view.me);
|
||||
if (!me) return null;
|
||||
const elite =
|
||||
action.elite ??
|
||||
Math.max(0, action.amount - (me.reserves - (me.elites?.reserves ?? 0)));
|
||||
if (typeof elite !== 'number') return null;
|
||||
const sources =
|
||||
action.homeworldSources !== undefined
|
||||
? action.homeworldSources
|
||||
: nativeShipmentSources(view, action.amount, elite);
|
||||
const physical = publicCustody(view);
|
||||
if (!physical || !sources) return null;
|
||||
try {
|
||||
quoteNativeReserveWithdrawal(
|
||||
physical.context,
|
||||
physical.custody,
|
||||
view.me,
|
||||
{ normal: action.amount - elite, elite },
|
||||
sources as NativeReserveSelections,
|
||||
);
|
||||
return { ...action, homeworldSources: sources };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NativeReserveError ||
|
||||
error instanceof HomeworldCustodyError
|
||||
)
|
||||
return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Spend at most one movement restoring a printed high threshold. Salusa has
|
||||
* priority; normal-only returns to Kaitain cannot undo its Sardaukar repair. */
|
||||
export function emperorHomeworldMoveActions(view: GameView): Action[] {
|
||||
const movement = view.homeworldMove;
|
||||
if (
|
||||
!movement ||
|
||||
movement.blocked ||
|
||||
movement.remaining <= 0 ||
|
||||
!view.advanced ||
|
||||
view.status !== 'playing' ||
|
||||
view.phase !== 5 ||
|
||||
view.active !== view.me ||
|
||||
view.players.find((p) => p.id === view.me)?.faction !== 'emperor'
|
||||
)
|
||||
return [];
|
||||
const physical = publicCustody(view);
|
||||
if (!physical) return [];
|
||||
const kaitain = physical.worlds.find(
|
||||
(home) => home.native === view.me && home.card === 'kaitain',
|
||||
);
|
||||
const salusa = physical.worlds.find(
|
||||
(home) => home.native === view.me && home.card === 'salusa_secundus',
|
||||
);
|
||||
if (!kaitain || !salusa) return [];
|
||||
const kaitainMinimum = HOMEWORLD_CARDS.find((card) => card.id === 'kaitain')!
|
||||
.high.reserves.min;
|
||||
const salusaMinimum = HOMEWORLD_CARDS.find(
|
||||
(card) => card.id === 'salusa_secundus',
|
||||
)!.high.reserves.min;
|
||||
const atKaitain = kaitain.forces[view.me],
|
||||
atSalusa = salusa.forces[view.me];
|
||||
const salusaDeficit = Math.max(0, salusaMinimum - atSalusa.elite);
|
||||
const kaitainDeficit = Math.max(
|
||||
0,
|
||||
kaitainMinimum - atKaitain.normal - atKaitain.elite,
|
||||
);
|
||||
let order: EmperorHomeworldMove | null = null;
|
||||
if (salusaDeficit > 0 && atKaitain.elite >= salusaDeficit)
|
||||
order = {
|
||||
origin: 'homeworld:emperor',
|
||||
forces: { normal: 0, elite: salusaDeficit },
|
||||
};
|
||||
else if (kaitainDeficit > 0 && atSalusa.normal >= kaitainDeficit)
|
||||
order = {
|
||||
origin: 'homeworld:emperor:salusa',
|
||||
forces: { normal: kaitainDeficit, elite: 0 },
|
||||
};
|
||||
if (!order) return [];
|
||||
try {
|
||||
quoteEmperorHomeworldMove(
|
||||
{
|
||||
...physical.context,
|
||||
status: view.status,
|
||||
phase: view.phase,
|
||||
currentPlayer: view.active,
|
||||
movesLeft: movement.remaining,
|
||||
},
|
||||
physical.custody,
|
||||
view.me,
|
||||
order,
|
||||
);
|
||||
return [
|
||||
{
|
||||
type: 'emperorHomeworldMove',
|
||||
event: movement.event,
|
||||
origin: order.origin,
|
||||
normal: order.forces.normal,
|
||||
elite: order.forces.elite,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof EmperorHomeworldMoveError ||
|
||||
error instanceof HomeworldCustodyError
|
||||
)
|
||||
return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Game } from './engine';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type HomeworldPaymentIncomeContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds'
|
||||
>;
|
||||
|
||||
export type HomeworldPaymentIncomeQuote = {
|
||||
gross: number;
|
||||
income: number;
|
||||
bank: number;
|
||||
low: boolean;
|
||||
};
|
||||
|
||||
/** Split one already eligible payment. Contributor exclusions, payment-unit
|
||||
* classification and timing belong to the caller. This does not resolve how
|
||||
* multiple contributors define a payment for rounding purposes.
|
||||
* This quotes physical low population only, not occupation entitlements. */
|
||||
export function quoteHomeworldPaymentIncome(
|
||||
context: HomeworldPaymentIncomeContext,
|
||||
ownerId: string,
|
||||
kind: 'shipment' | 'treachery',
|
||||
gross: number,
|
||||
): HomeworldPaymentIncomeQuote {
|
||||
if (!Number.isSafeInteger(gross) || gross < 0)
|
||||
throw new HomeworldCustodyError(
|
||||
'Homeworld payment income requires a nonnegative safe integer payment.',
|
||||
);
|
||||
if (kind !== 'shipment' && kind !== 'treachery')
|
||||
throw new HomeworldCustodyError('Unknown Homeworld payment income kind.');
|
||||
const faction = kind === 'shipment' ? 'guild' : 'emperor';
|
||||
const owner = context.players.find((player) => player.id === ownerId);
|
||||
if (!owner || owner.faction !== faction)
|
||||
throw new HomeworldCustodyError(
|
||||
`Only the ${faction === 'guild' ? 'Guild' : 'Emperor'} receives this payment income.`,
|
||||
);
|
||||
const card = kind === 'shipment' ? 'junction' : 'kaitain';
|
||||
const low = context.homeworlds?.custody
|
||||
? homeworldPopulations(
|
||||
homeworldContext(context),
|
||||
context.homeworlds.custody,
|
||||
).some(
|
||||
(home) =>
|
||||
home.native === ownerId && home.card === card && home.side === 'low',
|
||||
)
|
||||
: false;
|
||||
const income = low ? Math.ceil(gross / 2) : gross;
|
||||
return { gross, income, bank: gross - income, low };
|
||||
}
|
||||
|
||||
/** Compare the unresolved transaction and contributor rounding interpretations.
|
||||
* The caller may accept agreement; divergence requires an explicit ruling. */
|
||||
export function quoteGuildPaymentRounding(
|
||||
gross: number,
|
||||
contributions: number[],
|
||||
): { transaction: number; contribution: number; unambiguous: boolean } {
|
||||
if (
|
||||
!Number.isSafeInteger(gross) ||
|
||||
gross < 0 ||
|
||||
!Array.isArray(contributions) ||
|
||||
contributions.length > 2 ||
|
||||
!Array.from(contributions).every(
|
||||
(amount) => Number.isSafeInteger(amount) && amount >= 0,
|
||||
)
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'Guild rounding requires a nonnegative safe payment and at most two nonnegative safe contributions.',
|
||||
);
|
||||
const total = contributions.reduce((sum, amount) => sum + amount, 0);
|
||||
if (!Number.isSafeInteger(total) || total !== gross)
|
||||
throw new HomeworldCustodyError(
|
||||
'Guild contributions must sum to the original eligible payment.',
|
||||
);
|
||||
const transaction = Math.ceil(gross / 2);
|
||||
const contribution = contributions.reduce(
|
||||
(sum, amount) => sum + Math.ceil(amount / 2),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
transaction,
|
||||
contribution,
|
||||
unambiguous: transaction === contribution,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Action, GameView } from './engine';
|
||||
import { quoteGuildPaymentRounding } from './homeworld-payment-income';
|
||||
import { reserveShipmentCost, guildShipmentCost } from './shipment-price';
|
||||
import { territory } from './board';
|
||||
|
||||
/** Own funding and public native population only; no donor balance or hidden card access. */
|
||||
export function homeworldShipmentPaymentBlock(g: GameView, cost: number, allyPayment: number): string | null {
|
||||
const guild = g.players.find(p => p.faction === 'guild');
|
||||
const me = g.players.find(p => p.id === g.me)!;
|
||||
if (!guild || g.karamaShipping?.player === g.me ||
|
||||
!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return null;
|
||||
const contributions = [me.id === guild.id ? 0 : cost - allyPayment,
|
||||
me.ally === guild.id ? 0 : allyPayment];
|
||||
return quoteGuildPaymentRounding(contributions[0] + contributions[1], contributions).unambiguous
|
||||
? null : 'Low Junction rounding for two odd allied contributions awaits a ruling. Choose a different payment split.';
|
||||
}
|
||||
|
||||
export function botHomeworldShipmentPaymentAllowed(g: GameView, action: Action): boolean {
|
||||
if (!['ship', 'guildShip'].includes(action.type)) return true;
|
||||
if (!g.homeworlds?.worlds?.some(world => world.card === 'junction' && world.side === 'low')) return true;
|
||||
const me = g.players.find(p => p.id === g.me)!;
|
||||
const to = String(action.territory);
|
||||
const amount = action.noField ? 1 : Number(action.amount);
|
||||
const cost = action.type === 'ship'
|
||||
? reserveShipmentCost({ faction: me.faction, halfRate: me.faction === 'guild' ||
|
||||
g.players.some(p => p.faction === 'guild' && p.id === me.ally) || g.karamaShipping?.player === me.id }, territory(to).type, amount)
|
||||
: guildShipmentCost(to === 'reserves' ? 'reserves' : territory(to).type, amount);
|
||||
const share = action.allyPayment === undefined ? Math.max(0, cost - (me.spice ?? 0)) : Number(action.allyPayment);
|
||||
return !homeworldShipmentPaymentBlock(g, cost, share);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { HOMEWORLD_CARDS, type HomeworldId } from './homeworld-cards';
|
||||
import {
|
||||
homeworldForceGroups,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
} from './homeworld-custody';
|
||||
|
||||
export type HomeworldPopulation = {
|
||||
location: string;
|
||||
card: HomeworldId;
|
||||
native: string;
|
||||
population: number;
|
||||
side: 'high' | 'low';
|
||||
nativeBattleStrength: number;
|
||||
/** Printed potential, not an award or proof of occupation eligibility. */
|
||||
printedOccupiedSpice: number;
|
||||
/** Added only when the native faction is otherwise taking the named benefit. */
|
||||
extraFreeRevival: number;
|
||||
extraCharity: number;
|
||||
};
|
||||
|
||||
/** Public physical projection only. Occupier entitlements and effect timing are
|
||||
* separate: occupation can retain a low penalty after native population rises.
|
||||
* No player advantage, income, battle result or game action is applied here. */
|
||||
export function homeworldPopulations(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
): HomeworldPopulation[] {
|
||||
return homeworldForceGroups(context, custody).map((home) => {
|
||||
const faction = context.players.find((p) => p.id === home.native)!.faction;
|
||||
const card = HOMEWORLD_CARDS.find((candidate) =>
|
||||
home.secondary
|
||||
? candidate.id === 'salusa_secundus'
|
||||
: candidate.faction === faction && candidate.id !== 'salusa_secundus',
|
||||
)!;
|
||||
const native = home.forces[home.native];
|
||||
const population =
|
||||
card.reserveType === 'sardaukar'
|
||||
? native.elite
|
||||
: native.normal + native.elite;
|
||||
// Global minimum-high rule resolves Salusa's overlapping printed endpoints.
|
||||
const side = population >= card.high.reserves.min ? 'high' : 'low';
|
||||
const bonus = side === 'low' && card.id !== 'salusa_secundus' ? 1 : 0;
|
||||
return {
|
||||
location: home.id,
|
||||
card: card.id,
|
||||
native: home.native,
|
||||
population,
|
||||
side,
|
||||
nativeBattleStrength: card[side].battleStrength,
|
||||
printedOccupiedSpice: card.occupied.spiceIcons,
|
||||
extraFreeRevival: bonus,
|
||||
extraCharity: bonus,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { gameTerritories, splitLocation } from './board';
|
||||
import type { Action, GameView } from './engine';
|
||||
|
||||
/** Use the server's source-specific timing boundary for the selected group. */
|
||||
export function homeworldRevivalActionBlock(g: GameView, action: Action): string | null {
|
||||
const me = g.players.find((player) => player.id === g.me);
|
||||
if (!me || action.leader) return null;
|
||||
const source = action.type === 'revive'
|
||||
? 'normal'
|
||||
: action.type === 'card' && me.hand?.some((card) => card.id === action.card && card.effect === 'ghola')
|
||||
? 'ghola'
|
||||
: null;
|
||||
if (!source) return null;
|
||||
const amount = action.amount === undefined && source === 'ghola'
|
||||
? Math.min(5, me.tanks)
|
||||
: Number(action.amount);
|
||||
const elite = action.elite === undefined
|
||||
? Math.max(0, amount - (me.tanks - (me.elites?.tanks ?? 0)))
|
||||
: Number(action.elite);
|
||||
return g.homeworldRevivalBlocks?.find((block) =>
|
||||
block.source === source && block.amount === amount && block.elite === elite)?.reason ?? null;
|
||||
}
|
||||
|
||||
/** Consume the actor's projected destinations; never reconstruct eligibility
|
||||
* from reserves or another player's private state. */
|
||||
export function homeworldRevivalDeploymentCanAct(g: GameView): boolean {
|
||||
return !!(
|
||||
g.status === 'playing' &&
|
||||
g.homeworldRevivalDeployment?.player === g.me &&
|
||||
g.decision?.kind === 'homeworldRevivalDeployment' &&
|
||||
g.decision.player === g.me &&
|
||||
g.decision.event === g.homeworldRevivalDeployment.event &&
|
||||
!g.response &&
|
||||
!g.truthtrance &&
|
||||
!g.phaseOpening &&
|
||||
!g.automaticContinuationPending
|
||||
);
|
||||
}
|
||||
|
||||
export function homeworldRevivalDeploymentChoice(
|
||||
g: GameView,
|
||||
destination: string,
|
||||
): { blocked: string | null; action: Action | null } {
|
||||
const offer = g.homeworldRevivalDeployment;
|
||||
const target = offer?.destinations.find(
|
||||
(option) => option.id === destination,
|
||||
);
|
||||
let blocked: string | null = null;
|
||||
if (!homeworldRevivalDeploymentCanAct(g))
|
||||
blocked = 'Wait for the current choice to finish.';
|
||||
else if (offer!.blocked) blocked = offer!.blocked;
|
||||
else if (!target) blocked = 'Choose a current revival destination.';
|
||||
else if (target.blocked) blocked = target.blocked;
|
||||
else if (
|
||||
!Number.isSafeInteger(offer!.normal) ||
|
||||
!Number.isSafeInteger(offer!.elite) ||
|
||||
offer!.normal < 0 ||
|
||||
offer!.elite < 0 ||
|
||||
offer!.normal + offer!.elite <= 0 ||
|
||||
offer!.normal + offer!.elite > 20
|
||||
)
|
||||
blocked = 'The revived group is no longer available.';
|
||||
return {
|
||||
blocked,
|
||||
action: blocked
|
||||
? null
|
||||
: {
|
||||
type: 'decision',
|
||||
event: offer!.event,
|
||||
destination,
|
||||
amount: offer!.normal + offer!.elite,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** All profiles share this legal fallback: reinforce an occupied stronghold,
|
||||
* otherwise use the first permitted destination, or leave the group at home. */
|
||||
export function homeworldRevivalDeploymentActions(g: GameView): Action[] {
|
||||
if (!homeworldRevivalDeploymentCanAct(g)) return [];
|
||||
const offer = g.homeworldRevivalDeployment!;
|
||||
const me = g.players.find((player) => player.id === g.me);
|
||||
const strongholds = new Set(
|
||||
gameTerritories(g)
|
||||
.filter((territory) => territory.type === 'stronghold')
|
||||
.map((territory) => territory.id),
|
||||
);
|
||||
const occupied = new Set(
|
||||
Object.entries(me?.forces ?? {})
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key]) => splitLocation(key).territory),
|
||||
);
|
||||
const choices = offer.destinations
|
||||
.map((target) => ({
|
||||
target,
|
||||
action: homeworldRevivalDeploymentChoice(g, target.id).action,
|
||||
}))
|
||||
.filter((choice) => choice.action);
|
||||
const preferred = choices.find(
|
||||
({ target }) =>
|
||||
target.territory &&
|
||||
strongholds.has(target.territory) &&
|
||||
occupied.has(target.territory),
|
||||
);
|
||||
const action = preferred?.action ?? choices[0]?.action;
|
||||
return [action ?? { type: 'decision', event: offer.event, decline: true }];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Game } from './engine';
|
||||
import {
|
||||
homeworldForceGroups,
|
||||
HomeworldCustodyError,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { quoteNativeRevivalDeposit } from './homeworld-native-reserves';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
export type HomeworldRevivalDeploymentContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds'
|
||||
>;
|
||||
export type HomeworldRevivalDeploymentSource =
|
||||
| 'normal'
|
||||
| 'emperorExtra'
|
||||
| 'ghola';
|
||||
export type HomeworldRevivalDeploymentGroup = {
|
||||
amount: number;
|
||||
elite: number;
|
||||
free: number;
|
||||
};
|
||||
export type HomeworldRevivalDeploymentQuote = {
|
||||
kind: 'fedaykin' | 'tleilax';
|
||||
normal: number;
|
||||
elite: number;
|
||||
beforePopulation: number;
|
||||
afterPopulation: number;
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
function physicalWorlds(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
) {
|
||||
return homeworldForceGroups(context, custody)
|
||||
.map((home) => ({
|
||||
id: home.id,
|
||||
native: home.native,
|
||||
forces: Object.entries(home.forces)
|
||||
.filter(([, forces]) => forces.normal + forces.elite > 0)
|
||||
.map(([player, forces]) => [player, forces.normal, forces.elite])
|
||||
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Quote the eligible subset of one actual typed native revival deposit.
|
||||
* Timing, destinations, splitting and durable completion belong to the caller.
|
||||
* A newly attained high threshold remains an explicit unresolved boundary. */
|
||||
export function quoteHomeworldRevivalDeployment(
|
||||
before: HomeworldRevivalDeploymentContext,
|
||||
after: HomeworldRevivalDeploymentContext,
|
||||
player: string,
|
||||
source: HomeworldRevivalDeploymentSource,
|
||||
group: HomeworldRevivalDeploymentGroup,
|
||||
): HomeworldRevivalDeploymentQuote | null {
|
||||
if (
|
||||
!['normal', 'emperorExtra', 'ghola'].includes(source) ||
|
||||
!group ||
|
||||
typeof group !== 'object' ||
|
||||
Array.isArray(group) ||
|
||||
!Number.isSafeInteger(group.amount) ||
|
||||
group.amount <= 0 ||
|
||||
group.amount > 20 ||
|
||||
!Number.isSafeInteger(group.elite) ||
|
||||
group.elite < 0 ||
|
||||
group.elite > group.amount ||
|
||||
!Number.isSafeInteger(group.free) ||
|
||||
group.free < 0 ||
|
||||
group.free > group.amount
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'Revival deployment needs a valid source and exact newly returned physical group.',
|
||||
);
|
||||
if (!before.homeworlds?.custody && !after.homeworlds?.custody) return null;
|
||||
if (
|
||||
!before.homeworlds?.custody ||
|
||||
!after.homeworlds?.custody ||
|
||||
before.advanced !== after.advanced
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The Homeworld module cannot change during a revival deposit.',
|
||||
);
|
||||
const beforeContext = homeworldContext(before);
|
||||
const afterContext = homeworldContext(after);
|
||||
const expected = quoteNativeRevivalDeposit(
|
||||
beforeContext,
|
||||
before.homeworlds.custody,
|
||||
player,
|
||||
{ normal: group.amount - group.elite, elite: group.elite },
|
||||
);
|
||||
if (
|
||||
JSON.stringify(
|
||||
physicalWorlds(
|
||||
{ ...beforeContext, players: expected.players },
|
||||
expected.state,
|
||||
),
|
||||
) !== JSON.stringify(physicalWorlds(afterContext, after.homeworlds.custody))
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The revival deployment does not match its original typed native reserve deposit.',
|
||||
);
|
||||
const owner = beforeContext.players.find((seat) => seat.id === player)!;
|
||||
const kind =
|
||||
owner.faction === 'fremen' && group.elite > 0
|
||||
? 'fedaykin'
|
||||
: owner.faction === 'tleilaxu' && source === 'normal' && group.free > 0
|
||||
? 'tleilax'
|
||||
: null;
|
||||
if (!kind) return null;
|
||||
const card = kind === 'fedaykin' ? 'southern_hemisphere' : 'tleilax';
|
||||
const prior = homeworldPopulations(
|
||||
beforeContext,
|
||||
before.homeworlds.custody,
|
||||
).find((home) => home.native === player && home.card === card)!;
|
||||
const current = homeworldPopulations(
|
||||
afterContext,
|
||||
after.homeworlds.custody,
|
||||
).find((home) => home.native === player && home.card === card)!;
|
||||
if (current.side !== 'high') return null;
|
||||
return {
|
||||
kind,
|
||||
normal: kind === 'tleilax' ? group.free : 0,
|
||||
elite: kind === 'fedaykin' ? group.elite : 0,
|
||||
beforePopulation: prior.population,
|
||||
afterPopulation: current.population,
|
||||
blocked:
|
||||
prior.side === 'low'
|
||||
? 'Revival deployment when this return first reaches the high Homeworld threshold awaits a timing ruling.'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Game } from './engine';
|
||||
import { gameTerritories, location, MOBILE_STRONGHOLD, splitLocation } from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { fighterCount } from './advisors';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
|
||||
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
import { homeworldSpiritualAdvisorLimit } from './homeworld-mobility';
|
||||
import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
|
||||
|
||||
type Context = Pick<Game, 'advanced' | 'players' | 'homeworlds' | 'storm' | 'mobileStronghold' | 'moritaniTerror'>;
|
||||
export type HomeworldRevivalDestination = {
|
||||
id: string;
|
||||
name: string;
|
||||
territory?: string;
|
||||
sector?: number;
|
||||
homeworld?: string;
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
/** Only the complete earned group to one destination is admitted here.
|
||||
* Shipment-specific reactions remain explicit source boundaries. No token face,
|
||||
* opposing hand, spice balance or concealed denomination influences these lists. */
|
||||
export function homeworldRevivalDestinations(
|
||||
g: Context,
|
||||
player: string,
|
||||
grant: HomeworldRevivalDeploymentQuote,
|
||||
): HomeworldRevivalDestination[] {
|
||||
const p = g.players.find((seat) => seat.id === player);
|
||||
if (!p || !g.homeworlds?.custody)
|
||||
throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
|
||||
const amount = grant.normal + grant.elite;
|
||||
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
|
||||
const moritani = g.players.find((seat) => seat.faction === 'moritani');
|
||||
const advisors = grant.kind === 'tleilax' && bg && bg.reserves > 0 &&
|
||||
homeworldSpiritualAdvisorLimit(g, bg.id, 'polar_sink') > 0
|
||||
? 'Spiritual Advisors after Tleilax revival deployment await a shipment-classification ruling.' : null;
|
||||
const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
|
||||
if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
|
||||
return t.sectors.map((sector) => {
|
||||
const effectiveSector = t.id === MOBILE_STRONGHOLD
|
||||
? splitLocation(g.mobileStronghold!.location!).sector : sector;
|
||||
let blocked = grant.blocked ?? advisors;
|
||||
if (!blocked && effectiveSector !== 0 && effectiveSector === g.storm)
|
||||
blocked = grant.kind === 'fedaykin'
|
||||
? 'Fedaykin revival placement into storm awaits its own permission ruling.'
|
||||
: 'Revival deployment cannot enter a sector in storm.';
|
||||
if (!blocked && t.id === MOBILE_STRONGHOLD)
|
||||
blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
|
||||
blocked ??= territoryEntryBlock(g.players, p.id, t.id);
|
||||
if (!blocked && g.advanced && bg && bg.id !== p.id && fighterCount(bg, t.id) > 0)
|
||||
blocked = 'Intrusion after Homeworld revival placement awaits an entry-classification ruling.';
|
||||
if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
|
||||
!lowGrummanRevealBlock(g, moritani.id, amount) &&
|
||||
g.moritaniTerror?.tokens.some((token) => token.status === 'placed' && token.location === t.id))
|
||||
blocked = 'Terror after Homeworld revival placement awaits an entry-classification ruling.';
|
||||
return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
|
||||
territory: t.id, sector, blocked };
|
||||
});
|
||||
});
|
||||
if (grant.kind === 'tleilax') {
|
||||
for (const home of homeworldForceGroups(homeworldContext(g), g.homeworlds.custody)) {
|
||||
const native = g.players.find((seat) => seat.id === home.native)!;
|
||||
const card = HOMEWORLD_CARDS.find((card) => card.faction === native.faction && (card.id === 'salusa_secundus') === home.secondary)!;
|
||||
destinations.push({ id: home.id, name: card.name, homeworld: home.id,
|
||||
blocked: grant.blocked ??
|
||||
(p.ally === native.id && native.ally === p.id ? 'You cannot deploy to your ally’s Homeworld.' : null),
|
||||
});
|
||||
}
|
||||
}
|
||||
return destinations;
|
||||
}
|
||||
|
||||
/** A custody quote only: the engine commits the physical arrival and its
|
||||
* Ambassador continuation. No fee, new revival or shipment allowance is used. */
|
||||
export function quoteHomeworldRevivalDestination(
|
||||
g: Context,
|
||||
player: string,
|
||||
grant: HomeworldRevivalDeploymentQuote,
|
||||
destination: string,
|
||||
) {
|
||||
const selected = homeworldRevivalDestinations(g, player, grant).find((d) => d.id === destination);
|
||||
if (!selected || selected.blocked)
|
||||
throw new HomeworldCustodyError(selected?.blocked ?? 'Choose a currently eligible revival destination.');
|
||||
const forces = { normal: grant.normal, elite: grant.elite };
|
||||
const context = homeworldContext(g);
|
||||
if (selected.homeworld) {
|
||||
const native = homeworldForceGroups(context, g.homeworlds!.custody!)
|
||||
.find((home) => home.native === player && !home.secondary)!;
|
||||
const transfer = quoteHomeworldCustody(context, g.homeworlds!.custody!, [
|
||||
{ homeworld: native.id, player, withdraw: forces, deposit: { normal: 0, elite: 0 } },
|
||||
{ homeworld: selected.homeworld, player, withdraw: { normal: 0, elite: 0 }, deposit: forces },
|
||||
]);
|
||||
return { selected, transfer };
|
||||
}
|
||||
return { selected, transfer: quoteNativeReserveWithdrawal(context, g.homeworlds!.custody!, player, forces) };
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import type { Game } from './engine';
|
||||
import {
|
||||
homeworldArrivalSignature,
|
||||
appendHomeworldArrivalAmbassador,
|
||||
completeHomeworldArrivalAmbassador,
|
||||
validateHomeworldArrival,
|
||||
type HomeworldAmbassadorArrival,
|
||||
} from './homeworld-arrival';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import type {
|
||||
HomeworldRevivalDeploymentGroup,
|
||||
HomeworldRevivalDeploymentQuote,
|
||||
HomeworldRevivalDeploymentSource,
|
||||
} from './homeworld-revival-deployment';
|
||||
|
||||
export type HomeworldRevivalReturn = {
|
||||
progressVersion?: 1;
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
player: string;
|
||||
source: HomeworldRevivalDeploymentSource;
|
||||
card?: string;
|
||||
group: HomeworldRevivalDeploymentGroup;
|
||||
quote: HomeworldRevivalDeploymentQuote;
|
||||
stage: 'waiting' | 'choice' | 'arrival' | 'complete';
|
||||
resumeResponse?: Game['response'];
|
||||
destination?: string;
|
||||
signature: string;
|
||||
resumeSignature?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
};
|
||||
|
||||
export type HomeworldRevivalProgress = {
|
||||
event: string;
|
||||
stage: HomeworldRevivalReturn['stage'];
|
||||
signature: string;
|
||||
};
|
||||
|
||||
type Original = Pick<
|
||||
HomeworldRevivalReturn,
|
||||
| 'progressVersion'
|
||||
| 'event'
|
||||
| 'turn'
|
||||
| 'phase'
|
||||
| 'player'
|
||||
| 'source'
|
||||
| 'card'
|
||||
| 'group'
|
||||
| 'quote'
|
||||
>;
|
||||
type Context = Pick<
|
||||
Game,
|
||||
| 'turn'
|
||||
| 'phase'
|
||||
| 'players'
|
||||
| 'homeworlds'
|
||||
| 'pendingAmbassador'
|
||||
| 'homeworldRevivalProgress'
|
||||
>;
|
||||
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Homeworld revival return does not match its original typed revival and continuation.',
|
||||
);
|
||||
}
|
||||
function count(value: unknown, min = 0, max = 20): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
(value as number) >= min &&
|
||||
(value as number) <= max
|
||||
);
|
||||
}
|
||||
function text(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
function keys(value: unknown, allowed: string[]): boolean {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).every((key) => allowed.includes(key))
|
||||
);
|
||||
}
|
||||
function originalSignature(frame: Original): string {
|
||||
return JSON.stringify([
|
||||
'homeworldRevivalReturn',
|
||||
frame.event,
|
||||
frame.turn,
|
||||
frame.phase,
|
||||
frame.player,
|
||||
frame.source,
|
||||
frame.card ?? null,
|
||||
[frame.group.amount, frame.group.elite, frame.group.free],
|
||||
[
|
||||
frame.quote.kind,
|
||||
frame.quote.normal,
|
||||
frame.quote.elite,
|
||||
frame.quote.beforePopulation,
|
||||
frame.quote.afterPopulation,
|
||||
frame.quote.blocked,
|
||||
],
|
||||
// Preserve the exact signature bytes of legacy unversioned saves.
|
||||
...(frame.progressVersion === 1 ? ['progressVersion', 1] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Compatibility wrappers retain the original revival grant validation. */
|
||||
export function homeworldRevivalArrivalSignature(
|
||||
frame: HomeworldRevivalReturn,
|
||||
): string {
|
||||
return homeworldArrivalSignature(frame);
|
||||
}
|
||||
export function appendHomeworldRevivalAmbassador(
|
||||
frame: HomeworldRevivalReturn,
|
||||
entry: Parameters<typeof appendHomeworldArrivalAmbassador>[1],
|
||||
parent?: string,
|
||||
): void {
|
||||
validateOriginal(frame);
|
||||
if (frame.signature !== originalSignature(frame)) fail();
|
||||
appendHomeworldArrivalAmbassador(frame, entry, parent);
|
||||
}
|
||||
export function completeHomeworldRevivalAmbassador(
|
||||
frame: HomeworldRevivalReturn,
|
||||
event: string,
|
||||
): void {
|
||||
validateOriginal(frame);
|
||||
if (frame.signature !== originalSignature(frame)) fail();
|
||||
completeHomeworldArrivalAmbassador(frame, event);
|
||||
}
|
||||
function validateOriginal(frame: Original): void {
|
||||
if (
|
||||
(frame.progressVersion !== undefined && frame.progressVersion !== 1) ||
|
||||
!text(frame.event) ||
|
||||
!text(frame.player) ||
|
||||
!count(frame.turn, 1, Number.MAX_SAFE_INTEGER) ||
|
||||
!count(frame.phase, 0, 8) ||
|
||||
!['normal', 'emperorExtra', 'ghola'].includes(frame.source) ||
|
||||
(frame.source === 'ghola' ? !text(frame.card) : frame.card !== undefined) ||
|
||||
!keys(frame.group, ['amount', 'elite', 'free']) ||
|
||||
!count(frame.group.amount, 1) ||
|
||||
!count(frame.group.elite, 0, frame.group.amount) ||
|
||||
!count(frame.group.free, 0, frame.group.amount) ||
|
||||
(frame.source !== 'normal' && frame.group.free !== 0) ||
|
||||
!keys(frame.quote, [
|
||||
'kind',
|
||||
'normal',
|
||||
'elite',
|
||||
'beforePopulation',
|
||||
'afterPopulation',
|
||||
'blocked',
|
||||
]) ||
|
||||
frame.quote.blocked !== null ||
|
||||
!count(frame.quote.normal) ||
|
||||
!count(frame.quote.elite) ||
|
||||
!count(frame.quote.beforePopulation) ||
|
||||
!count(frame.quote.afterPopulation) ||
|
||||
frame.quote.afterPopulation - frame.quote.beforePopulation !==
|
||||
frame.group.amount
|
||||
)
|
||||
fail();
|
||||
const quote = frame.quote;
|
||||
if (quote.kind === 'fedaykin') {
|
||||
if (
|
||||
quote.normal !== 0 ||
|
||||
!count(quote.elite, 1, 3) ||
|
||||
quote.elite !== frame.group.elite ||
|
||||
quote.beforePopulation < 3
|
||||
)
|
||||
fail();
|
||||
} else if (quote.kind === 'tleilax') {
|
||||
if (
|
||||
frame.source !== 'normal' ||
|
||||
frame.group.elite !== 0 ||
|
||||
quote.elite !== 0 ||
|
||||
quote.normal !== frame.group.free ||
|
||||
quote.normal < 1 ||
|
||||
quote.beforePopulation < 9
|
||||
)
|
||||
fail();
|
||||
} else fail();
|
||||
}
|
||||
|
||||
/** Captured response proof survives JSON key reordering, but never omitted values. */
|
||||
export function homeworldRevivalResumeSignature(
|
||||
response: Game['response'],
|
||||
): string {
|
||||
if (response === null) return 'null';
|
||||
if (
|
||||
!keys(response, [
|
||||
'kind',
|
||||
'owner',
|
||||
'passed',
|
||||
'recipient',
|
||||
'amount',
|
||||
'intent',
|
||||
])
|
||||
)
|
||||
fail();
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(response).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Construct only after the quoted deposit actually returned its exact group. */
|
||||
export function makeHomeworldRevivalReturn(
|
||||
original: Omit<Original, 'progressVersion'>,
|
||||
): HomeworldRevivalReturn {
|
||||
if (
|
||||
!keys(original, [
|
||||
'event',
|
||||
'turn',
|
||||
'phase',
|
||||
'player',
|
||||
'source',
|
||||
'card',
|
||||
'group',
|
||||
'quote',
|
||||
])
|
||||
)
|
||||
fail();
|
||||
validateOriginal(original);
|
||||
const versioned = {
|
||||
...structuredClone(original),
|
||||
progressVersion: 1 as const,
|
||||
};
|
||||
return {
|
||||
...versioned,
|
||||
stage: 'waiting',
|
||||
signature: originalSignature(versioned),
|
||||
};
|
||||
}
|
||||
|
||||
/** Only engine transition boundaries create progress; readers never reconstruct it. */
|
||||
export function makeHomeworldRevivalProgress(
|
||||
frame: HomeworldRevivalReturn,
|
||||
): HomeworldRevivalProgress {
|
||||
validateOriginal(frame);
|
||||
if (
|
||||
frame.progressVersion !== 1 ||
|
||||
frame.signature !== originalSignature(frame) ||
|
||||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage)
|
||||
)
|
||||
fail();
|
||||
return {
|
||||
event: frame.event,
|
||||
stage: frame.stage,
|
||||
signature: JSON.stringify([
|
||||
'homeworldRevivalProgress',
|
||||
frame.signature,
|
||||
frame.event,
|
||||
frame.stage,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Entitlement is the original receipt. Current native population never grants it again. */
|
||||
export function validateHomeworldRevivalReturn(
|
||||
g: Context,
|
||||
frame: HomeworldRevivalReturn | null | undefined,
|
||||
): void {
|
||||
if (frame === null || frame === undefined) {
|
||||
if (g.homeworldRevivalProgress !== undefined) fail();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!keys(frame, [
|
||||
'progressVersion',
|
||||
'event',
|
||||
'turn',
|
||||
'phase',
|
||||
'player',
|
||||
'source',
|
||||
'card',
|
||||
'group',
|
||||
'quote',
|
||||
'stage',
|
||||
'resumeResponse',
|
||||
'destination',
|
||||
'signature',
|
||||
'resumeSignature',
|
||||
'ambassadors',
|
||||
'arrivalSignature',
|
||||
])
|
||||
)
|
||||
fail();
|
||||
validateOriginal(frame);
|
||||
if (
|
||||
frame.signature !== originalSignature(frame) ||
|
||||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
|
||||
!g.homeworlds?.custody ||
|
||||
(frame.stage !== 'complete' &&
|
||||
(g.turn !== frame.turn || g.phase !== frame.phase)) ||
|
||||
(frame.stage === 'complete' && g.turn < frame.turn)
|
||||
)
|
||||
fail();
|
||||
if (frame.progressVersion === 1) {
|
||||
const expected = makeHomeworldRevivalProgress(frame);
|
||||
const progress = g.homeworldRevivalProgress;
|
||||
if (
|
||||
!keys(progress, ['event', 'stage', 'signature']) ||
|
||||
progress?.event !== expected.event ||
|
||||
progress.stage !== expected.stage ||
|
||||
progress.signature !== expected.signature
|
||||
)
|
||||
fail();
|
||||
} else if (g.homeworldRevivalProgress !== undefined) fail();
|
||||
const owners = g.players.filter((seat) => seat.id === frame.player);
|
||||
const owner = owners[0];
|
||||
if (
|
||||
owners.length !== 1 ||
|
||||
owner.faction !== (frame.quote.kind === 'fedaykin' ? 'fremen' : 'tleilaxu')
|
||||
)
|
||||
fail();
|
||||
if (frame.stage === 'waiting' || frame.stage === 'choice') {
|
||||
if (frame.ambassadors !== undefined || frame.arrivalSignature !== undefined)
|
||||
fail();
|
||||
const elites = owner.elites?.reserves ?? 0;
|
||||
if (
|
||||
!count(owner.reserves) ||
|
||||
!count(elites, 0, owner.reserves) ||
|
||||
elites < frame.quote.elite ||
|
||||
owner.reserves - elites < frame.quote.normal
|
||||
)
|
||||
fail();
|
||||
}
|
||||
if (frame.stage === 'arrival' || frame.stage === 'complete')
|
||||
validateHomeworldArrival(g, frame, 'revivalEvent');
|
||||
if (frame.stage === 'waiting') {
|
||||
if (
|
||||
frame.resumeResponse !== undefined ||
|
||||
frame.resumeSignature !== undefined ||
|
||||
frame.destination !== undefined
|
||||
)
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frame.resumeResponse === undefined ||
|
||||
frame.resumeSignature !==
|
||||
homeworldRevivalResumeSignature(frame.resumeResponse)
|
||||
)
|
||||
fail();
|
||||
if (
|
||||
frame.stage === 'choice'
|
||||
? frame.destination !== undefined
|
||||
: !text(frame.destination)
|
||||
)
|
||||
fail();
|
||||
const response = frame.resumeResponse;
|
||||
if (
|
||||
response !== null &&
|
||||
(response.kind !== 'revivalIncome' ||
|
||||
response.recipient !== frame.player ||
|
||||
!g.players.some(
|
||||
(seat) => seat.id === response.owner && seat.faction === 'tleilaxu',
|
||||
) ||
|
||||
!count(response.amount, 1, Number.MAX_SAFE_INTEGER) ||
|
||||
(frame.source === 'ghola' && response.amount !== 1) ||
|
||||
!Array.isArray(response.passed) ||
|
||||
new Set(response.passed).size !== response.passed.length ||
|
||||
response.passed.some((id) => !g.players.some((seat) => seat.id === id)) ||
|
||||
(response.intent !== undefined && !text(response.intent)))
|
||||
)
|
||||
fail();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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';
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
) {
|
||||
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 ||
|
||||
me.shipped
|
||||
) {
|
||||
result.blocked = 'Use Homeworld shipment during your unused shipment turn.';
|
||||
return result;
|
||||
}
|
||||
if (option.blocked) {
|
||||
result.blocked = option.blocked;
|
||||
return result;
|
||||
}
|
||||
const context = {
|
||||
advanced: view.advanced,
|
||||
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,
|
||||
});
|
||||
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: 'homeworldShip',
|
||||
event: option.event,
|
||||
destination,
|
||||
sources,
|
||||
allyPayment,
|
||||
};
|
||||
} 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 ||
|
||||
me.shipped
|
||||
)
|
||||
return [];
|
||||
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') &&
|
||||
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' ? 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 initial = homeworldShipmentChoice(view, target.world.id, selection);
|
||||
const quote = homeworldShipmentChoice(
|
||||
view,
|
||||
target.world.id,
|
||||
selection,
|
||||
Math.max(0, initial.cost - (me.spice ?? 0)),
|
||||
);
|
||||
if (
|
||||
quote.action &&
|
||||
!actions.some(
|
||||
(action) => JSON.stringify(action) === JSON.stringify(quote.action),
|
||||
)
|
||||
)
|
||||
actions.push(quote.action);
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
type HomeworldReserveSeat,
|
||||
} from './homeworld-custody';
|
||||
|
||||
export type HomeworldShipmentContext = Omit<
|
||||
HomeworldCustodyContext,
|
||||
'players'
|
||||
> & {
|
||||
players: (HomeworldReserveSeat & { ally: string | null })[];
|
||||
};
|
||||
export type HomeworldShipmentIntent = {
|
||||
player: string;
|
||||
destination: string;
|
||||
sources: Record<string, HomeworldForces>;
|
||||
};
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
function requireShipment(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(message);
|
||||
}
|
||||
|
||||
/** E3 world-to-world transport: use the actual native or foreign source pool.
|
||||
* Guild may also return a foreign garrison to its own world; other own-world
|
||||
* exceptions need separately established permission. The caller owns timing, usage,
|
||||
* funding, bank payment, responses and arrival effects. Guild's E3 half rate
|
||||
* applies to its own shipment; an alliance alone does not grant that rate. */
|
||||
export function quoteHomeworldShipment(
|
||||
context: HomeworldShipmentContext,
|
||||
custody: HomeworldCustody,
|
||||
intent: HomeworldShipmentIntent,
|
||||
) {
|
||||
// Validate the entire public custody before using any selected source.
|
||||
const worlds = homeworldForceGroups(context, custody);
|
||||
requireShipment(
|
||||
context.players.every(
|
||||
(p) =>
|
||||
p.ally === null ||
|
||||
(typeof p.ally === 'string' &&
|
||||
p.ally !== p.id &&
|
||||
context.players.some((other) => other.id === p.ally)),
|
||||
),
|
||||
'Homeworld shipment needs valid seated alliance identities.',
|
||||
);
|
||||
requireShipment(
|
||||
record(intent) &&
|
||||
Object.keys(intent).length === 3 &&
|
||||
typeof intent.player === 'string' &&
|
||||
typeof intent.destination === 'string' &&
|
||||
record(intent.sources),
|
||||
'Choose a seated shipper, a destination Homeworld and typed source groups.',
|
||||
);
|
||||
const player = context.players.find((p) => p.id === intent.player);
|
||||
const destination = worlds.find((w) => w.id === intent.destination);
|
||||
requireShipment(
|
||||
player && destination,
|
||||
'Choose an active Homeworld and seated shipper.',
|
||||
);
|
||||
const native = context.players.find((p) => p.id === destination.native)!;
|
||||
requireShipment(
|
||||
native.id !== player.id || player.faction === 'guild',
|
||||
'Shipment to your own Homeworld needs its specific return permission.',
|
||||
);
|
||||
requireShipment(
|
||||
!(player.ally === native.id && native.ally === player.id),
|
||||
'You cannot ship to your ally’s Homeworld.',
|
||||
);
|
||||
const sources = Object.entries(intent.sources);
|
||||
requireShipment(
|
||||
sources.length === 1 ||
|
||||
(sources.length === 2 &&
|
||||
context.advanced &&
|
||||
player.faction === 'emperor' &&
|
||||
sources.every(([id]) =>
|
||||
['homeworld:emperor', 'homeworld:emperor:salusa'].includes(id),
|
||||
)),
|
||||
'Use one source Homeworld, or combine Emperor’s own Kaitain and Salusa forces in Advanced play.',
|
||||
);
|
||||
let normal = 0;
|
||||
let elite = 0;
|
||||
for (const [id, forces] of sources) {
|
||||
requireShipment(
|
||||
id !== destination.id && worlds.some((w) => w.id === id),
|
||||
'Shipment needs a distinct active source Homeworld.',
|
||||
);
|
||||
requireShipment(
|
||||
record(forces) &&
|
||||
Object.keys(forces).length === 2 &&
|
||||
whole(forces.normal) &&
|
||||
whole(forces.elite) &&
|
||||
forces.normal + forces.elite > 0 &&
|
||||
forces.normal + forces.elite <= 20,
|
||||
'Each source needs a positive physical normal and special counter group.',
|
||||
);
|
||||
normal += forces.normal;
|
||||
elite += forces.elite;
|
||||
}
|
||||
const amount = normal + elite;
|
||||
requireShipment(
|
||||
amount > 0 && amount <= 20,
|
||||
'A shipment cannot exceed the faction’s twenty physical counters.',
|
||||
);
|
||||
const transferred = quoteHomeworldCustody(context, custody, [
|
||||
...sources.map(([homeworld, forces]) => ({
|
||||
homeworld,
|
||||
player: player.id,
|
||||
withdraw: { normal: forces.normal, elite: forces.elite },
|
||||
deposit: { normal: 0, elite: 0 },
|
||||
})),
|
||||
{
|
||||
homeworld: destination.id,
|
||||
player: player.id,
|
||||
withdraw: { normal: 0, elite: 0 },
|
||||
deposit: { normal, elite },
|
||||
},
|
||||
]);
|
||||
return {
|
||||
...transferred,
|
||||
amount,
|
||||
elite,
|
||||
cost: player.faction === 'guild' ? Math.ceil(amount / 2) : amount,
|
||||
sources: transferred.receipts.filter((r) => r.homeworld !== destination.id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
HomeworldCombatLossError,
|
||||
quoteHomeworldCombatLoss,
|
||||
type HomeworldCombatLossContext,
|
||||
} from './homeworld-combat-loss';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
} from './homeworld-custody';
|
||||
|
||||
export type HomeworldSubstitutionRequest = {
|
||||
location: string;
|
||||
player: string;
|
||||
amount: number;
|
||||
/** Bound to the resolved battle by the caller, never all historical deaths. */
|
||||
cyborgsLost: number;
|
||||
};
|
||||
export class HomeworldSubstitutionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'HomeworldSubstitutionError';
|
||||
}
|
||||
}
|
||||
function requireSubstitution(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldSubstitutionError(message);
|
||||
}
|
||||
|
||||
/** Exact post-casualty exchange. Event identity, declaration timing and Karama
|
||||
* permission are caller-owned. The lost Cyborgs stay at this battle location;
|
||||
* surviving Suboids take their place in the Tanks, without revival or transport. */
|
||||
export function quoteHomeworldSubstitution(
|
||||
context: HomeworldCombatLossContext,
|
||||
custody: HomeworldCustody,
|
||||
request: HomeworldSubstitutionRequest,
|
||||
) {
|
||||
try {
|
||||
requireSubstitution(
|
||||
!!request &&
|
||||
typeof request === 'object' &&
|
||||
!Array.isArray(request) &&
|
||||
Number.isSafeInteger(request.amount) &&
|
||||
request.amount > 0 &&
|
||||
Number.isSafeInteger(request.cyborgsLost) &&
|
||||
request.cyborgsLost <= 7 &&
|
||||
request.amount <= request.cyborgsLost,
|
||||
'Exchange a positive number of Suboids for Cyborgs lost in this battle.',
|
||||
);
|
||||
// A zero loss validates the entire physical inventory and detaches custody
|
||||
// without temporarily increasing an accumulated battle-loss counter.
|
||||
const before = quoteHomeworldCombatLoss(context, custody, {
|
||||
location: request.location,
|
||||
player: request.player,
|
||||
losses: { normal: 0, elite: 0 },
|
||||
});
|
||||
const owner = before.players.find(
|
||||
(player) => player.id === request.player,
|
||||
)!;
|
||||
requireSubstitution(
|
||||
owner.faction === 'ixians' &&
|
||||
owner.eliteTanks >= request.cyborgsLost &&
|
||||
owner.battleLosses >= request.cyborgsLost &&
|
||||
before.receipt.before.normal >= request.amount,
|
||||
'Use surviving Ixian Suboids here and the Cyborg casualties of this battle.',
|
||||
);
|
||||
const exchange = quoteHomeworldCustody(
|
||||
{ advanced: context.advanced, players: before.players },
|
||||
before.custody,
|
||||
[
|
||||
{
|
||||
homeworld: request.location,
|
||||
player: request.player,
|
||||
withdraw: { normal: request.amount, elite: 0 },
|
||||
deposit: { normal: 0, elite: request.amount },
|
||||
},
|
||||
],
|
||||
);
|
||||
const players = before.players.map((player, index) => ({
|
||||
...player,
|
||||
...exchange.players[index],
|
||||
eliteTanks:
|
||||
player.eliteTanks - (player.id === request.player ? request.amount : 0),
|
||||
}));
|
||||
const after = quoteHomeworldCombatLoss(
|
||||
{ advanced: context.advanced, players },
|
||||
exchange.state,
|
||||
{
|
||||
location: request.location,
|
||||
player: request.player,
|
||||
losses: { normal: 0, elite: 0 },
|
||||
},
|
||||
);
|
||||
return {
|
||||
custody: after.custody,
|
||||
players: after.players,
|
||||
receipt: {
|
||||
location: request.location,
|
||||
player: request.player,
|
||||
amount: request.amount,
|
||||
cyborgsLost: request.cyborgsLost,
|
||||
before: { ...before.receipt.before },
|
||||
after: { ...after.receipt.after },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HomeworldCustodyError ||
|
||||
error instanceof HomeworldCombatLossError
|
||||
)
|
||||
throw new HomeworldSubstitutionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { Game } from './engine';
|
||||
import {
|
||||
gameTerritories,
|
||||
location,
|
||||
MOBILE_STRONGHOLD,
|
||||
splitLocation,
|
||||
validLocation,
|
||||
} from './board';
|
||||
import { fighterCount } from './advisors';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
quoteHomeworldCustody,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
|
||||
import { lowGrummanRevealBlock } from './homeworld-collection';
|
||||
|
||||
export type HomeworldVictoryReinforcementContext = Pick<
|
||||
Game,
|
||||
| 'advanced'
|
||||
| 'players'
|
||||
| 'homeworlds'
|
||||
| 'storm'
|
||||
| 'mobileStronghold'
|
||||
| 'moritaniTerror'
|
||||
>;
|
||||
export type HomeworldVictoryWitness = {
|
||||
event: string;
|
||||
turn: number;
|
||||
player: string;
|
||||
territory: string;
|
||||
result: 'normal' | 'traitor';
|
||||
};
|
||||
export type HomeworldVictoryReinforcementDestination = {
|
||||
id: string;
|
||||
name: string;
|
||||
territory?: string;
|
||||
sector?: number;
|
||||
homeworld?: string;
|
||||
blocked: string | null;
|
||||
};
|
||||
export type HomeworldVictoryReinforcementQuote = HomeworldVictoryWitness & {
|
||||
population: number;
|
||||
survivors: number;
|
||||
amount: 0 | 1;
|
||||
blocked: string | null;
|
||||
destinations: HomeworldVictoryReinforcementDestination[];
|
||||
};
|
||||
|
||||
function validCount(n: unknown): n is number {
|
||||
return Number.isSafeInteger(n) && (n as number) >= 0 && (n as number) <= 20;
|
||||
}
|
||||
function invalid(message: string): never {
|
||||
throw new HomeworldCustodyError(message);
|
||||
}
|
||||
|
||||
/** Printed Caladan high face; E3 p9's continuous native threshold applies at
|
||||
* actual use, after winner casualties. The caller proves this original victory
|
||||
* against its battle receipt and owns ordering, single use and entry reactions.
|
||||
* Face Dance priority is not selected here. */
|
||||
export function quoteHomeworldVictoryReinforcement(
|
||||
g: HomeworldVictoryReinforcementContext,
|
||||
victory: HomeworldVictoryWitness,
|
||||
): HomeworldVictoryReinforcementQuote {
|
||||
if (
|
||||
!victory ||
|
||||
typeof victory !== 'object' ||
|
||||
Array.isArray(victory) ||
|
||||
Object.keys(victory).sort().join(',') !==
|
||||
'event,player,result,territory,turn' ||
|
||||
typeof victory.event !== 'string' ||
|
||||
!victory.event ||
|
||||
typeof victory.player !== 'string' ||
|
||||
!victory.player ||
|
||||
typeof victory.territory !== 'string' ||
|
||||
!victory.territory ||
|
||||
!Number.isSafeInteger(victory.turn) ||
|
||||
victory.turn < 1 ||
|
||||
!['normal', 'traitor'].includes(victory.result)
|
||||
)
|
||||
invalid(
|
||||
'Caladan reinforcement requires one original winning battle receipt.',
|
||||
);
|
||||
const owner = g.players.find((p) => p.id === victory.player);
|
||||
if (!owner || g.players.filter((p) => p.id === victory.player).length !== 1)
|
||||
invalid('The original winning faction must be seated.');
|
||||
const board = gameTerritories(g).find((t) => t.id === victory.territory);
|
||||
const worlds = g.homeworlds?.custody
|
||||
? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody)
|
||||
: [];
|
||||
const home = worlds.find((world) => world.id === victory.territory);
|
||||
if (!board && !home)
|
||||
invalid('Choose the actual battle territory or active Homeworld.');
|
||||
let survivors = 0;
|
||||
if (home) {
|
||||
const forces = home.forces[owner.id];
|
||||
survivors = forces ? forces.normal + forces.elite : 0;
|
||||
} else {
|
||||
for (const [key, amount] of Object.entries(owner.forces)) {
|
||||
const at = splitLocation(key);
|
||||
if (
|
||||
!validLocation(at.territory, at.sector) ||
|
||||
location(at.territory, at.sector) !== key ||
|
||||
!validCount(amount)
|
||||
)
|
||||
invalid(
|
||||
'Caladan reinforcement requires valid surviving physical forces.',
|
||||
);
|
||||
if (at.territory === victory.territory) survivors += amount;
|
||||
}
|
||||
}
|
||||
if (!validCount(survivors))
|
||||
invalid('The surviving army exceeds its physical force inventory.');
|
||||
const population = g.homeworlds?.custody
|
||||
? (homeworldPopulations(homeworldContext(g), g.homeworlds.custody).find(
|
||||
(world) => world.native === owner.id && world.card === 'caladan',
|
||||
)?.population ?? 0)
|
||||
: 0;
|
||||
const quote: HomeworldVictoryReinforcementQuote = {
|
||||
...victory,
|
||||
population,
|
||||
survivors,
|
||||
amount: 0,
|
||||
blocked: null,
|
||||
destinations: [],
|
||||
};
|
||||
if (owner.faction !== 'atreides' || !g.homeworlds?.custody) return quote;
|
||||
if (population < 6)
|
||||
return {
|
||||
...quote,
|
||||
blocked:
|
||||
'Caladan requires at least six native reserves for victory reinforcement.',
|
||||
};
|
||||
if (survivors === 0)
|
||||
return {
|
||||
...quote,
|
||||
blocked: 'An Atreides force must survive at the battle location.',
|
||||
};
|
||||
// All native reserves already occupy Caladan. Moving one within that same
|
||||
// custody pool cannot add a counter or return one from the Tanks.
|
||||
if (home?.native === owner.id)
|
||||
return {
|
||||
...quote,
|
||||
blocked:
|
||||
'The native reserve forces are already on Caladan; no additional force can be placed there.',
|
||||
};
|
||||
quote.amount = 1;
|
||||
if (home) {
|
||||
const native = g.players.find((seat) => seat.id === home.native)!;
|
||||
quote.destinations.push({
|
||||
id: home.id,
|
||||
name: HOMEWORLD_CARDS.find(
|
||||
(card) =>
|
||||
card.faction === native.faction &&
|
||||
(card.id === 'salusa_secundus') === home.secondary,
|
||||
)!.name,
|
||||
homeworld: home.id,
|
||||
blocked:
|
||||
owner.ally === native.id && native.ally === owner.id
|
||||
? 'You cannot reinforce on your ally’s Homeworld.'
|
||||
: null,
|
||||
});
|
||||
return quote;
|
||||
}
|
||||
if (!Number.isSafeInteger(g.storm) || g.storm < 1 || g.storm > 18)
|
||||
invalid('Caladan reinforcement requires the current storm sector.');
|
||||
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
|
||||
const moritani = g.players.find((seat) => seat.faction === 'moritani');
|
||||
quote.destinations = board!.sectors.map((sector) => {
|
||||
let blocked: string | null = null;
|
||||
if (board!.id === MOBILE_STRONGHOLD)
|
||||
blocked =
|
||||
'Caladan reinforcement in the Hidden Mobile Stronghold awaits its entry-classification ruling.';
|
||||
if (!blocked && sector === g.storm)
|
||||
blocked =
|
||||
'Caladan reinforcement into a sector in storm awaits its placement-permission ruling.';
|
||||
blocked ??= territoryEntryBlock(g.players, owner.id, board!.id);
|
||||
if (!blocked && g.advanced && bg && fighterCount(bg, board!.id) > 0)
|
||||
blocked =
|
||||
'Intrusion after Caladan reinforcement awaits an entry-classification ruling.';
|
||||
if (
|
||||
!blocked &&
|
||||
moritani &&
|
||||
owner.id !== moritani.ally &&
|
||||
!lowGrummanRevealBlock(g, moritani.id, 1) &&
|
||||
g.moritaniTerror?.tokens.some(
|
||||
(token) => token.status === 'placed' && token.location === board!.id,
|
||||
)
|
||||
)
|
||||
blocked =
|
||||
'Terror after Caladan reinforcement awaits an entry-classification ruling.';
|
||||
return {
|
||||
id: location(board!.id, sector),
|
||||
name: `${board!.name}${sector ? ` · sector ${sector}` : ''}`,
|
||||
territory: board!.id,
|
||||
sector,
|
||||
blocked,
|
||||
};
|
||||
});
|
||||
return quote;
|
||||
}
|
||||
|
||||
/** One actual native normal counter; never a revival, fee or ordinary shipment. */
|
||||
export function quoteHomeworldVictoryReinforcementDestination(
|
||||
g: HomeworldVictoryReinforcementContext,
|
||||
victory: HomeworldVictoryWitness,
|
||||
destination: string,
|
||||
) {
|
||||
const quote = quoteHomeworldVictoryReinforcement(g, victory);
|
||||
const selected = quote.destinations.find(
|
||||
(choice) => choice.id === destination,
|
||||
);
|
||||
if (quote.amount !== 1 || !selected || selected.blocked)
|
||||
invalid(
|
||||
quote.blocked ??
|
||||
selected?.blocked ??
|
||||
'Choose one eligible destination at the won battle location.',
|
||||
);
|
||||
const forces = { normal: 1, elite: 0 };
|
||||
const context = homeworldContext(g);
|
||||
const transfer = selected.homeworld
|
||||
? quoteHomeworldCustody(context, g.homeworlds!.custody!, [
|
||||
{
|
||||
homeworld: 'homeworld:atreides',
|
||||
player: victory.player,
|
||||
withdraw: forces,
|
||||
deposit: { normal: 0, elite: 0 },
|
||||
},
|
||||
{
|
||||
homeworld: selected.homeworld,
|
||||
player: victory.player,
|
||||
withdraw: { normal: 0, elite: 0 },
|
||||
deposit: forces,
|
||||
},
|
||||
])
|
||||
: quoteNativeReserveWithdrawal(
|
||||
context,
|
||||
g.homeworlds!.custody!,
|
||||
victory.player,
|
||||
forces,
|
||||
);
|
||||
return { quote, selected, transfer };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Game } from './engine';
|
||||
import type { HomeworldAmbassadorArrival } from './homeworld-arrival';
|
||||
import type { HomeworldVictoryWitness } from './homeworld-victory-reinforcement';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
|
||||
export type HomeworldVictoryReturn = HomeworldVictoryWitness & {
|
||||
phase: 6;
|
||||
stage: 'waiting' | 'choice' | 'arrival' | 'complete';
|
||||
signature: string;
|
||||
offer?: { population: number; survivors: number; blocked: string | null; signature: string };
|
||||
destination?: string;
|
||||
ambassadors?: HomeworldAmbassadorArrival[];
|
||||
arrivalSignature?: string;
|
||||
};
|
||||
export type HomeworldVictoryObligation = {
|
||||
event: string;
|
||||
completed: boolean;
|
||||
/** Absent only on completed development saves written before stage receipts. */
|
||||
stage?: HomeworldVictoryReturn['stage'];
|
||||
signature: string;
|
||||
};
|
||||
|
||||
function fail(): never {
|
||||
throw new HomeworldCustodyError('The saved Caladan reinforcement no longer matches its original victory and completion obligation.');
|
||||
}
|
||||
function originalSignature(witness: HomeworldVictoryWitness) {
|
||||
return JSON.stringify(['caladanVictory', witness.event, witness.turn, witness.player, witness.territory, witness.result]);
|
||||
}
|
||||
function witnessValid(witness: HomeworldVictoryWitness) {
|
||||
return typeof witness.event === 'string' && !!witness.event &&
|
||||
Number.isSafeInteger(witness.turn) && witness.turn > 0 &&
|
||||
typeof witness.player === 'string' && !!witness.player &&
|
||||
typeof witness.territory === 'string' && !!witness.territory &&
|
||||
['normal', 'traitor'].includes(witness.result);
|
||||
}
|
||||
export function makeHomeworldVictoryReturn(witness: HomeworldVictoryWitness): HomeworldVictoryReturn {
|
||||
if (!witnessValid(witness)) fail();
|
||||
return { ...witness, phase: 6, stage: 'waiting', signature: originalSignature(witness) };
|
||||
}
|
||||
export function homeworldVictoryObligationSignature(context: NonNullable<Game['lastBattleContext']>, completed: boolean,
|
||||
stage?: HomeworldVictoryReturn['stage']) {
|
||||
return JSON.stringify(['caladanObligation', context.event, context.turn, context.territory,
|
||||
context.winner, context.result, completed, ...(stage === undefined ? [] : [stage])]);
|
||||
}
|
||||
export function homeworldVictoryOfferSignature(frame: HomeworldVictoryReturn,
|
||||
offer: Pick<NonNullable<HomeworldVictoryReturn['offer']>, 'population' | 'survivors' | 'blocked'>) {
|
||||
return JSON.stringify(['caladanOffer', frame.signature, offer.population, offer.survivors, offer.blocked]);
|
||||
}
|
||||
|
||||
/** Original battle facts and the independent obligation survive nullable choices.
|
||||
* Arrival chain validation is shared with other Homeworld arrivals by the engine. */
|
||||
export function validateHomeworldVictoryReturn(g: Pick<Game, 'turn' | 'phase' | 'players' | 'homeworlds' | 'lastBattleContext' | 'homeworldVictoryReinforcement'>) {
|
||||
const frame = g.homeworldVictoryReinforcement;
|
||||
const context = g.lastBattleContext;
|
||||
const obligation = context?.caladanReinforcement;
|
||||
if (obligation) {
|
||||
if (!frame || obligation.event !== context!.event || frame.event !== obligation.event ||
|
||||
typeof obligation.completed !== 'boolean' ||
|
||||
obligation.signature !== homeworldVictoryObligationSignature(context!, obligation.completed, obligation.stage) ||
|
||||
(obligation.stage === undefined ? !obligation.completed : obligation.stage !== frame.stage) ||
|
||||
obligation.completed !== (frame.stage === 'complete')) fail();
|
||||
}
|
||||
if (frame === undefined || frame === null) return;
|
||||
if (typeof frame !== 'object' || Array.isArray(frame) ||
|
||||
Object.keys(frame).some((key) => !['event', 'turn', 'phase', 'player', 'territory', 'result', 'stage',
|
||||
'signature', 'offer', 'destination', 'ambassadors', 'arrivalSignature'].includes(key)) ||
|
||||
!witnessValid(frame) || frame.signature !== originalSignature(frame) || frame.phase !== 6 ||
|
||||
!['waiting', 'choice', 'arrival', 'complete'].includes(frame.stage) ||
|
||||
!g.homeworlds?.custody ||
|
||||
g.players.filter((p) => p.id === frame.player && p.faction === 'atreides').length !== 1 ||
|
||||
frame.turn > g.turn) fail();
|
||||
if (frame.stage !== 'complete' || frame.event === context?.event) {
|
||||
if (!obligation || (frame.stage !== 'complete' && (frame.turn !== g.turn || g.phase !== 6)) ||
|
||||
context!.event !== frame.event || context!.turn !== frame.turn ||
|
||||
context!.territory !== frame.territory || context!.winner !== frame.player ||
|
||||
context!.result !== frame.result) fail();
|
||||
}
|
||||
const offer = frame.offer;
|
||||
if (offer && (typeof offer !== 'object' || Array.isArray(offer) ||
|
||||
Object.keys(offer).some((key) => !['population', 'survivors', 'blocked', 'signature'].includes(key)) ||
|
||||
!Number.isSafeInteger(offer.population) || offer.population < 6 || offer.population > 20 ||
|
||||
!Number.isSafeInteger(offer.survivors) || offer.survivors < 1 || offer.survivors > 20 ||
|
||||
offer.blocked !== null && (typeof offer.blocked !== 'string' || !offer.blocked) ||
|
||||
offer.signature !== homeworldVictoryOfferSignature(frame, offer))) fail();
|
||||
if (frame.stage === 'waiting' || frame.stage === 'choice') {
|
||||
if (frame.destination !== undefined || frame.ambassadors !== undefined || frame.arrivalSignature !== undefined ||
|
||||
(frame.stage === 'waiting' ? offer !== undefined : !offer)) fail();
|
||||
} else if (typeof frame.destination !== 'string' || !frame.destination || !Array.isArray(frame.ambassadors) ||
|
||||
typeof frame.arrivalSignature !== 'string' || (!offer && frame.destination !== 'decline')) fail();
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Context = Pick<
|
||||
> & {
|
||||
players: readonly Pick<Game['players'][number], 'id' | 'faction' | 'hand'>[];
|
||||
territories: readonly { id: string; sectors: readonly number[] }[];
|
||||
combatLocations?: readonly { id: string; kind: 'territory' | 'homeworld' }[];
|
||||
physicalCards: readonly { id: string }[];
|
||||
};
|
||||
function requireIx(value: unknown, message: string): asserts value {
|
||||
@@ -43,6 +44,18 @@ export function quoteIxSubstitutionCancellation(
|
||||
owner?.faction === 'ixians',
|
||||
'The canceled substitution needs its Ixian winner.',
|
||||
);
|
||||
const homeworld = g.combatLocations?.find(
|
||||
(location) => location.id === p.territory && location.kind === 'homeworld',
|
||||
);
|
||||
const territory = g.territories.find(
|
||||
(location) => location.id === p.territory,
|
||||
);
|
||||
requireIx(
|
||||
homeworld
|
||||
? p.territory.startsWith('homeworld:') && !territory
|
||||
: !!territory && !p.territory.startsWith('homeworld:'),
|
||||
'The canceled substitution needs a current canonical combat location.',
|
||||
);
|
||||
let context: BattleCleanupContextQuote;
|
||||
try {
|
||||
context = validateBattleCleanupContext({
|
||||
@@ -51,7 +64,10 @@ export function quoteIxSubstitutionCancellation(
|
||||
battlePresent: !!g.battle,
|
||||
lastBattle: g.lastBattle,
|
||||
playerIds: g.players.map((p) => p.id),
|
||||
territoryIds: g.territories.map((t) => t.id),
|
||||
territoryIds: [
|
||||
...g.territories.map((t) => t.id),
|
||||
...(homeworld ? [homeworld.id] : []),
|
||||
],
|
||||
territory: p.territory,
|
||||
winner: p.player,
|
||||
context: g.lastBattleContext,
|
||||
@@ -63,20 +79,22 @@ export function quoteIxSubstitutionCancellation(
|
||||
: 'Invalid substitution battle receipt.',
|
||||
);
|
||||
}
|
||||
const territory = g.territories.find((t) => t.id === p.territory)!;
|
||||
const counts = (value: unknown): value is Record<string, number> =>
|
||||
record(value) &&
|
||||
(!homeworld || Object.keys(value).length === 1) &&
|
||||
Object.entries(value).every(
|
||||
([key, count]) =>
|
||||
territory.sectors.some(
|
||||
(sector) => key === `${territory.id}:${sector}`,
|
||||
) &&
|
||||
(homeworld
|
||||
? key === homeworld.id
|
||||
: territory!.sectors.some(
|
||||
(sector) => key === `${territory!.id}:${sector}`,
|
||||
)) &&
|
||||
Number.isSafeInteger(count) &&
|
||||
Number(count) > 0,
|
||||
);
|
||||
requireIx(
|
||||
counts(p.losses) && counts(p.sources) && counts(p.recover),
|
||||
'The canceled substitution needs its original casualty and exchange sectors.',
|
||||
'The canceled substitution needs its original casualty and exchange locations.',
|
||||
);
|
||||
const total = (value: Record<string, number>) =>
|
||||
Object.values(value).reduce((a, b) => a + b, 0);
|
||||
@@ -104,13 +122,13 @@ export function quoteIxSubstitutionCancellation(
|
||||
);
|
||||
return {
|
||||
player: owner.id,
|
||||
territory: territory.id,
|
||||
territory: p.territory,
|
||||
context,
|
||||
decision: p.cards.length
|
||||
? {
|
||||
kind: 'battleCards' as const,
|
||||
player: owner.id,
|
||||
territory: territory.id,
|
||||
territory: p.territory,
|
||||
cards: [...p.cards],
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Game } from './engine';
|
||||
import { homeworldContext } from './homeworld-game';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
|
||||
export type JunctionOffer = {
|
||||
event: string;
|
||||
turn: number;
|
||||
owner: string;
|
||||
recipient: string;
|
||||
rate: 'half' | 'full';
|
||||
};
|
||||
|
||||
/** A public, optional quote for the recipient's current shipment opportunity.
|
||||
* It is not a second action allowance or a durable grant for a later turn. */
|
||||
export function currentJunctionOffer(g: Game): JunctionOffer | null {
|
||||
const offer = g.junctionOffer;
|
||||
if (!offer || !junctionSponsor(g) || offer.owner !== junctionSponsor(g) ||
|
||||
offer.turn !== g.turn || offer.recipient !== g.active || g.phase !== 5 ||
|
||||
g.status !== 'playing' || g.players.find((p) => p.id === offer.recipient)?.shipped)
|
||||
return null;
|
||||
return offer;
|
||||
}
|
||||
|
||||
export function junctionSponsor(g: Game): string | null {
|
||||
if (!g.homeworlds?.custody) return null;
|
||||
return homeworldPopulations(homeworldContext(g), g.homeworlds.custody)
|
||||
.find((world) => world.card === 'junction' && world.side === 'high')?.native ?? null;
|
||||
}
|
||||
|
||||
/** Only public turn and population data bind the opportunity. Hand changes or
|
||||
* the sponsor's private balance cannot reveal themselves through this token. */
|
||||
export function junctionOfferEvent(g: Game): string {
|
||||
return JSON.stringify([g.turn, g.phase, g.active, junctionSponsor(g),
|
||||
g.players.find((p) => p.id === g.active)?.shipped ?? null]);
|
||||
}
|
||||
|
||||
export function junctionOfferIntegrity(g: Game): void {
|
||||
const offer = g.junctionOffer;
|
||||
if (offer == null) return;
|
||||
if (typeof offer !== 'object' || Array.isArray(offer) ||
|
||||
Object.keys(offer).sort().join(',') !== 'event,owner,rate,recipient,turn' ||
|
||||
typeof offer.event !== 'string' || !offer.event.length ||
|
||||
!Number.isSafeInteger(offer.turn) || offer.turn < 1 || offer.turn > g.turn ||
|
||||
!g.homeworlds?.custody || !g.players.some((p) => p.id === offer.owner && p.faction === 'guild') ||
|
||||
offer.owner === offer.recipient || !g.players.some((p) => p.id === offer.recipient) ||
|
||||
!['half', 'full'].includes(offer.rate))
|
||||
throw new HomeworldCustodyError('The saved Junction offer needs its original Guild, recipient, turn and tariff.');
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
} = {
|
||||
blocked: null,
|
||||
cost: 0,
|
||||
amount: 0,
|
||||
elite: 0,
|
||||
ownPayment: 0,
|
||||
allyPayment,
|
||||
advisors: false,
|
||||
action: null,
|
||||
};
|
||||
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;
|
||||
}
|
||||
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,
|
||||
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,
|
||||
);
|
||||
const entryBlock = territoryEntryBlock(
|
||||
view.players,
|
||||
me.id,
|
||||
arrival.territory,
|
||||
result.advisors,
|
||||
);
|
||||
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,
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
MOBILE_LOCATION,
|
||||
splitLocation,
|
||||
territory,
|
||||
validLocation,
|
||||
} from './board';
|
||||
import {
|
||||
HomeworldCustodyError,
|
||||
homeworldForceGroups,
|
||||
quoteHomeworldCustody,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyChange,
|
||||
type HomeworldForces,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
import type { HomeworldShipmentContext } from './homeworld-shipment';
|
||||
|
||||
export type JunctionTransportContext = HomeworldShipmentContext & {
|
||||
storm: number;
|
||||
board: Record<
|
||||
string,
|
||||
{
|
||||
/** All physical counters, including the elite subset. */
|
||||
forces: Record<string, number>;
|
||||
eliteForces: Record<string, number>;
|
||||
advisors?: Record<string, { lockedTurn?: number }>;
|
||||
}
|
||||
>;
|
||||
/** Ordinary Arrakis pointer, never the mobile interior itself. */
|
||||
mobileStronghold?: string | null;
|
||||
};
|
||||
export type JunctionTransportIntent = {
|
||||
player: string;
|
||||
sponsor: string;
|
||||
destination: string;
|
||||
sources: Record<string, HomeworldForces>;
|
||||
rate: 'half' | 'full';
|
||||
};
|
||||
export type JunctionTransportSource = {
|
||||
key: string;
|
||||
before: HomeworldForces;
|
||||
after: HomeworldForces;
|
||||
};
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
function requireTransport(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new HomeworldCustodyError(message);
|
||||
}
|
||||
function canonicalSector(key: string) {
|
||||
const { territory: id, sector } = splitLocation(key);
|
||||
return key === `${id}:${sector}` && validLocation(id, sector);
|
||||
}
|
||||
const eliteLimit = (faction: string) =>
|
||||
faction === 'emperor'
|
||||
? 5
|
||||
: faction === 'fremen'
|
||||
? 3
|
||||
: faction === 'ixians'
|
||||
? 7
|
||||
: 0;
|
||||
|
||||
/** Native population gate only; retained occupation effects need engine policy. */
|
||||
export function junctionSponsorEligible(
|
||||
context: HomeworldShipmentContext,
|
||||
custody: HomeworldCustody,
|
||||
sponsor: string,
|
||||
) {
|
||||
const player = context.players.find((p) => p.id === sponsor);
|
||||
return (
|
||||
player?.faction === 'guild' &&
|
||||
homeworldPopulations(context, custody).some(
|
||||
(p) =>
|
||||
p.location === 'homeworld:guild' &&
|
||||
p.native === sponsor &&
|
||||
p.side === 'high',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Physical quote for an already authorized Junction transport offer.
|
||||
* Native-high checks use current population only; occupation eligibility,
|
||||
* timing, once-per-turn usage, offer identity and payment remain caller duties.
|
||||
* Arrakis outputs withdraw sources only: the caller must validate capacity,
|
||||
* mobile entry and advisor stance before placing the arrival. Paid transport
|
||||
* rejects ordinary storm sectors; the protected mobile interior is sector zero.
|
||||
* No concealed force, private hand, other player's board or spice is inspected. */
|
||||
export function quoteJunctionTransport(
|
||||
context: JunctionTransportContext,
|
||||
custody: HomeworldCustody,
|
||||
intent: JunctionTransportIntent,
|
||||
) {
|
||||
const worlds = homeworldForceGroups(context, custody);
|
||||
requireTransport(
|
||||
whole(context.storm) && context.storm <= 18 && record(context.board),
|
||||
'Junction transport needs the current public board and storm.',
|
||||
);
|
||||
requireTransport(
|
||||
context.players.every(
|
||||
(p) =>
|
||||
p.ally === null ||
|
||||
(typeof p.ally === 'string' &&
|
||||
p.ally !== p.id &&
|
||||
context.players.some((other) => other.id === p.ally)),
|
||||
),
|
||||
'Junction transport needs valid seated alliance identities.',
|
||||
);
|
||||
requireTransport(
|
||||
record(intent) &&
|
||||
Object.keys(intent).length === 5 &&
|
||||
typeof intent.player === 'string' &&
|
||||
typeof intent.sponsor === 'string' &&
|
||||
typeof intent.destination === 'string' &&
|
||||
record(intent.sources) &&
|
||||
(intent.rate === 'half' || intent.rate === 'full'),
|
||||
'Choose a recipient, Guild sponsor, destination, typed sources and transport rate.',
|
||||
);
|
||||
const player = context.players.find((p) => p.id === intent.player);
|
||||
const sponsor = context.players.find((p) => p.id === intent.sponsor);
|
||||
requireTransport(
|
||||
player && sponsor?.faction === 'guild' && player.id !== sponsor.id,
|
||||
'Junction transport must be granted by the seated Guild to another faction.',
|
||||
);
|
||||
requireTransport(
|
||||
junctionSponsorEligible(context, custody, sponsor.id),
|
||||
'Junction transport requires its current native high population.',
|
||||
);
|
||||
const pointer = context.mobileStronghold;
|
||||
requireTransport(
|
||||
pointer === undefined ||
|
||||
pointer === null ||
|
||||
(typeof pointer === 'string' &&
|
||||
canonicalSector(pointer) &&
|
||||
pointer !== MOBILE_LOCATION),
|
||||
'The mobile stronghold needs a canonical Arrakis pointer.',
|
||||
);
|
||||
const destinationWorld = worlds.find((w) => w.id === intent.destination);
|
||||
requireTransport(
|
||||
destinationWorld ||
|
||||
(canonicalSector(intent.destination) &&
|
||||
(intent.destination !== MOBILE_LOCATION || !!pointer)),
|
||||
'Choose an active Homeworld or canonical Arrakis destination.',
|
||||
);
|
||||
const destinationKind = destinationWorld
|
||||
? ('homeworld' as const)
|
||||
: ('arrakis' as const);
|
||||
if (!destinationWorld) {
|
||||
const sector = splitLocation(intent.destination).sector;
|
||||
requireTransport(
|
||||
sector === 0 || sector !== context.storm,
|
||||
'The destination is in storm.',
|
||||
);
|
||||
}
|
||||
if (destinationWorld) {
|
||||
const native = context.players.find(
|
||||
(p) => p.id === destinationWorld.native,
|
||||
)!;
|
||||
requireTransport(
|
||||
!(player.ally === native.id && native.ally === player.id),
|
||||
'You cannot ship to your ally’s Homeworld.',
|
||||
);
|
||||
}
|
||||
const board = Object.hasOwn(context.board, player.id)
|
||||
? context.board[player.id]
|
||||
: undefined;
|
||||
requireTransport(
|
||||
record(board) && record(board.forces) && record(board.eliteForces),
|
||||
'Junction transport needs the recipient’s current physical board counters.',
|
||||
);
|
||||
let boardTotal = 0;
|
||||
let boardElite = 0;
|
||||
for (const [key, count] of Object.entries(board.forces)) {
|
||||
requireTransport(
|
||||
canonicalSector(key) &&
|
||||
whole(count) &&
|
||||
count <= 20 &&
|
||||
(key !== MOBILE_LOCATION || !!pointer),
|
||||
'Junction transport needs canonical physical board counts.',
|
||||
);
|
||||
boardTotal += count;
|
||||
}
|
||||
for (const [key, count] of Object.entries(board.eliteForces)) {
|
||||
requireTransport(
|
||||
canonicalSector(key) &&
|
||||
whole(count) &&
|
||||
count <= (Object.hasOwn(board.forces, key) ? board.forces[key] : 0),
|
||||
'Special counters must be a subset of the physical force group.',
|
||||
);
|
||||
boardElite += count;
|
||||
}
|
||||
const worldTotals = worlds.reduce(
|
||||
(sum, w) => {
|
||||
const group = w.forces[player.id];
|
||||
return {
|
||||
total: sum.total + (group?.normal ?? 0) + (group?.elite ?? 0),
|
||||
elite: sum.elite + (group?.elite ?? 0),
|
||||
};
|
||||
},
|
||||
{ total: 0, elite: 0 },
|
||||
);
|
||||
requireTransport(
|
||||
boardTotal + worldTotals.total <= 20 &&
|
||||
boardElite + worldTotals.elite <= eliteLimit(player.faction),
|
||||
'Board and Homeworld custody exceed the faction’s physical counter supply.',
|
||||
);
|
||||
const entries = Object.entries(intent.sources);
|
||||
requireTransport(
|
||||
entries.length > 0,
|
||||
'Choose a positive physical source group.',
|
||||
);
|
||||
const originWorld = worlds.find((w) => w.id === entries[0][0]);
|
||||
const originKind = originWorld
|
||||
? ('homeworld' as const)
|
||||
: ('arrakis' as const);
|
||||
if (originWorld)
|
||||
requireTransport(
|
||||
entries.length === 1 ||
|
||||
(entries.length === 2 &&
|
||||
context.advanced &&
|
||||
player.faction === 'emperor' &&
|
||||
entries.every(([key]) =>
|
||||
['homeworld:emperor', 'homeworld:emperor:salusa'].includes(key),
|
||||
)),
|
||||
'Use one source Homeworld, or combine Emperor’s native Kaitain and Salusa groups.',
|
||||
);
|
||||
const boardForces = { ...board.forces };
|
||||
const boardEliteForces = { ...board.eliteForces };
|
||||
const changes: HomeworldCustodyChange[] = [];
|
||||
const sources: JunctionTransportSource[] = [];
|
||||
let origin: string | undefined;
|
||||
let normal = 0;
|
||||
let elite = 0;
|
||||
for (const [key, selected] of entries) {
|
||||
requireTransport(
|
||||
record(selected) &&
|
||||
Object.keys(selected).length === 2 &&
|
||||
whole(selected.normal) &&
|
||||
whole(selected.elite) &&
|
||||
selected.normal + selected.elite > 0 &&
|
||||
selected.normal + selected.elite <= 20,
|
||||
'Each source needs positive typed physical normal and special counters.',
|
||||
);
|
||||
normal += selected.normal;
|
||||
elite += selected.elite;
|
||||
if (originWorld) {
|
||||
requireTransport(
|
||||
worlds.some((w) => w.id === key) && key !== intent.destination,
|
||||
'Transport needs a distinct active source Homeworld.',
|
||||
);
|
||||
changes.push({
|
||||
homeworld: key,
|
||||
player: player.id,
|
||||
withdraw: { ...selected },
|
||||
deposit: { normal: 0, elite: 0 },
|
||||
});
|
||||
origin ??= key;
|
||||
} else {
|
||||
requireTransport(
|
||||
canonicalSector(key) && (key !== MOBILE_LOCATION || !!pointer),
|
||||
'Choose a canonical, active Arrakis source sector.',
|
||||
);
|
||||
const source = splitLocation(key);
|
||||
requireTransport(
|
||||
source.sector === 0 || source.sector !== context.storm,
|
||||
'The source is in storm.',
|
||||
);
|
||||
requireTransport(
|
||||
origin === undefined || origin === source.territory,
|
||||
'A transport group must come from one Arrakis territory.',
|
||||
);
|
||||
origin = source.territory;
|
||||
requireTransport(
|
||||
!destinationWorld ||
|
||||
player.faction !== 'beneGesserit' ||
|
||||
!board.advisors?.[source.territory],
|
||||
'Bene Gesserit advisors cannot ship to a Homeworld.',
|
||||
);
|
||||
const total = Object.hasOwn(board.forces, key) ? board.forces[key] : 0;
|
||||
const special = Object.hasOwn(board.eliteForces, key)
|
||||
? board.eliteForces[key]
|
||||
: 0;
|
||||
const before = { normal: total - special, elite: special };
|
||||
requireTransport(
|
||||
selected.normal <= before.normal && selected.elite <= before.elite,
|
||||
'The selected Arrakis sector does not hold those typed physical counters.',
|
||||
);
|
||||
const after = {
|
||||
normal: before.normal - selected.normal,
|
||||
elite: before.elite - selected.elite,
|
||||
};
|
||||
if (after.normal + after.elite)
|
||||
boardForces[key] = after.normal + after.elite;
|
||||
else delete boardForces[key];
|
||||
if (after.elite) boardEliteForces[key] = after.elite;
|
||||
else delete boardEliteForces[key];
|
||||
sources.push({ key, before, after });
|
||||
}
|
||||
}
|
||||
const amount = normal + elite;
|
||||
requireTransport(
|
||||
amount <= 20,
|
||||
'A transport cannot exceed twenty physical counters.',
|
||||
);
|
||||
if (!destinationWorld && !originWorld)
|
||||
requireTransport(
|
||||
splitLocation(intent.destination).territory !== origin,
|
||||
'Cross transport needs a different destination territory.',
|
||||
);
|
||||
if (destinationWorld)
|
||||
changes.push({
|
||||
homeworld: destinationWorld.id,
|
||||
player: player.id,
|
||||
withdraw: { normal: 0, elite: 0 },
|
||||
deposit: { normal, elite },
|
||||
});
|
||||
const transferred = quoteHomeworldCustody(context, custody, changes);
|
||||
if (originWorld)
|
||||
sources.push(
|
||||
...transferred.receipts
|
||||
.filter((r) => r.homeworld !== intent.destination)
|
||||
.map((r) => ({
|
||||
key: r.homeworld,
|
||||
before: { ...r.before },
|
||||
after: { ...r.after },
|
||||
})),
|
||||
);
|
||||
const fullCost =
|
||||
destinationWorld ||
|
||||
territory(splitLocation(intent.destination).territory).type === 'stronghold'
|
||||
? amount
|
||||
: amount * 2;
|
||||
return {
|
||||
...transferred,
|
||||
boardForces,
|
||||
boardEliteForces,
|
||||
sources,
|
||||
origin: origin!,
|
||||
originKind,
|
||||
destinationKind,
|
||||
amount,
|
||||
elite,
|
||||
cost: intent.rate === 'half' ? Math.ceil(fullCost / 2) : fullCost,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validAmbassadorResume } from './ambassador-resume';
|
||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||
import {
|
||||
validateGuildAmbassadorArrivalContext,
|
||||
GuildAmbassadorContinuationError,
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
} from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { validateAmbassadors } from './ecaz-ambassadors';
|
||||
import {
|
||||
homeworldMobileStrongholdMovementBlock,
|
||||
homeworldSpiritualAdvisorLimit,
|
||||
} from './homeworld-mobility';
|
||||
|
||||
export class MovementCancellationError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -148,6 +153,11 @@ export type AmbassadorRelocationContext = Pick<
|
||||
| 'players'
|
||||
| 'pendingAmbassador'
|
||||
| 'ecazAmbassadors'
|
||||
| 'homeworlds'
|
||||
| 'homeworldRevivalReturn'
|
||||
| 'homeworldRevivalProgress'
|
||||
| 'homeworldVictoryReinforcement'
|
||||
| 'lastBattleContext'
|
||||
>;
|
||||
|
||||
/** Historical receipt only: a child may already have changed alliances, forces
|
||||
@@ -163,7 +173,7 @@ export function validateAmbassadorRelocationContext(
|
||||
g.status === 'playing' &&
|
||||
integer(g.turn) &&
|
||||
g.turn > 0 &&
|
||||
(g.phase === 1 || g.phase === 5) &&
|
||||
ambassadorPhaseAllowed(g) &&
|
||||
Array.isArray(g.players) &&
|
||||
new Set(g.players.map((p) => p.id)).size === g.players.length &&
|
||||
entry &&
|
||||
@@ -343,6 +353,8 @@ export function quoteMovementCancellation(
|
||||
};
|
||||
}
|
||||
if (response.kind === 'mobileStronghold') {
|
||||
const blocked = homeworldMobileStrongholdMovementBlock(g, owner.id);
|
||||
requireContext(!blocked, blocked ?? 'This stronghold cannot move.');
|
||||
const pending = g.pendingMobileMove;
|
||||
requireContext(
|
||||
g.phase === 0 &&
|
||||
@@ -458,7 +470,10 @@ export function quoteMovementCancellation(
|
||||
);
|
||||
return {
|
||||
kind: 'advisorFlip',
|
||||
successor: owner.reserves > 0 ? 'advisor' : 'none',
|
||||
successor:
|
||||
homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0
|
||||
? 'advisor'
|
||||
: 'none',
|
||||
};
|
||||
}
|
||||
if (response.advisorResume === 'declaration') {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MovementCancellationError,
|
||||
} from './karama-movement-cancellation';
|
||||
import { TERROR_KINDS, TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
import { validateTerrorEntrySignature } from './terror-entry-receipt';
|
||||
|
||||
export class MoritaniAllianceCancellationError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -80,6 +81,11 @@ export function quoteMoritaniAllianceCancellation(
|
||||
'This Enemy of My Enemy opportunity is no longer current.',
|
||||
);
|
||||
const entrants = g.players.filter((p) => p.id === entry.entrant);
|
||||
try {
|
||||
validateTerrorEntrySignature(entry);
|
||||
} catch (error) {
|
||||
throw new MoritaniAllianceCancellationError((error as Error).message);
|
||||
}
|
||||
requireSource(
|
||||
entrants.length === 1 &&
|
||||
entrants[0].id !== response.owner &&
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SPICE_CARDS, type SpiceCard } from './cards';
|
||||
import { gameTerritories } from './board';
|
||||
import { presenceAt } from './force-presence';
|
||||
import { ecazOccupancyRelation } from './ecaz-occupy';
|
||||
import { homeworldMovementForesightBlock } from './homeworld-mobility';
|
||||
import {
|
||||
quoteBattleBoard,
|
||||
type BoardContext,
|
||||
@@ -26,6 +27,7 @@ type Context = Pick<
|
||||
| 'mobileStronghold'
|
||||
| 'spiceDeck'
|
||||
| 'spiceDiscard'
|
||||
| 'homeworlds'
|
||||
>;
|
||||
type AdvisorContext = BoardContext & { turn: number };
|
||||
const whole = (n: unknown): n is number =>
|
||||
@@ -164,7 +166,10 @@ export function quoteMovementPhaseStart(
|
||||
phaseOpening: { passed: [], initialize: true },
|
||||
};
|
||||
const advisor = quoteAdvisorBattleOffer(g);
|
||||
const atreides = g.players.find((p) => p.faction === 'atreides'),
|
||||
const atreides = g.players.find(
|
||||
(p) =>
|
||||
p.faction === 'atreides' && !homeworldMovementForesightBlock(g, p.id),
|
||||
),
|
||||
guild = g.players.find((p) => p.faction === 'guild');
|
||||
let refill = false,
|
||||
hasCards = false;
|
||||
|
||||
+235
-13
@@ -2,6 +2,7 @@ import { STRONGHOLD_CARDS } from './stronghold-cards';
|
||||
import { FACTION_RULES } from './faction-reference';
|
||||
import { FACTIONS, PHASES } from './catalog';
|
||||
import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
import { HOMEWORLD_CARDS } from './homeworld-cards';
|
||||
|
||||
export const RULE_CHECKLIST_AREAS = [
|
||||
'Implementation',
|
||||
@@ -28,6 +29,7 @@ export type RuleTopic = {
|
||||
| 'Advanced & expansions';
|
||||
coverage: 'Implemented' | 'Partial' | 'Planned';
|
||||
summary: string;
|
||||
searchText?: string;
|
||||
steps: string[];
|
||||
example?: string;
|
||||
related?: string[];
|
||||
@@ -121,6 +123,7 @@ const phaseDetails = [
|
||||
'The highlighted player may raise the bid or pass. A new high bid clears previous passes.',
|
||||
'Holding Karama permits a bid above your available spice. If outbid, keep the card. A winning bid above your funds requires Karama.',
|
||||
'After everyone else passes, the winner chooses spice payment or Karama. The table then resolves applicable Emperor-income and Harkonnen-bonus responses.',
|
||||
'In Homeworld development tables with Emperor, the last completed auction opens End of Bidding. High Kaitain paid discards and CHOAM closing sales or trades may occur in either order. Finish your opportunity when ready; another card action clears readiness before the phase advances.',
|
||||
],
|
||||
[
|
||||
'Choose the number of forces to revive. The server tracks the free allowance and the phase limit across multiple requests.',
|
||||
@@ -136,7 +139,7 @@ const phaseDetails = [
|
||||
'A Fremen move using the two-territory advantage has a Karama response. Cancellation leaves the forces in place and limits this move to one territory; city ornithopters and a later extra move keep their normal rules.',
|
||||
'Guild transport can move a group across the planet or return it to reserves. Ordinary movement is checked against range, storm and stronghold occupancy.',
|
||||
'You may pass through an ally’s territory on a legal route that ends elsewhere. The printed clarification also permits shipping into allied territory followed by departure; that temporary arrival and its departure obligation are not yet supported by the table.',
|
||||
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory is supported; combined combat, shared desert collection and the Fremen endgame exception remain unfinished.',
|
||||
'Ecaz and its reciprocal ally may share territories permanently and count as one faction toward stronghold capacity. This applies to normal shipment, ground routes and Ambassador arrivals. It does not grant extra movement, bypass storm or mobile-stronghold entry rules, or apply to homeworlds. Their three jointly occupied stronghold victory is supported; shared desert allocation is also supported. Combined combat and the Fremen endgame exception remain unfinished.',
|
||||
'A restored pending physical shipment is checked again before forces or spice are committed. If its saved turn, force allocation, destination, price or authorized contribution no longer matches the table, it is rejected without changing the saved declaration.',
|
||||
'Finish shipment and movement explicitly. Any Bene Gesserit free-shipment decision and its response must finish first.',
|
||||
'Advanced Guild may choose when to take its complete shipment-and-movement turn. The table offers a choice before another player starts. Guild cannot interrupt a turn already underway.',
|
||||
@@ -150,9 +153,11 @@ const phaseDetails = [
|
||||
'A winner with played cards chooses which to discard before the next battle or collection.',
|
||||
],
|
||||
[
|
||||
'The game collects spice automatically when this phase begins.',
|
||||
'Ordinary collection resolves automatically when this phase begins. If Ecaz and its ally both collect from a desert territory, their shared pool waits for allocation.',
|
||||
'Only forces in the sector containing spice collect it. Remaining spice stays on the board.',
|
||||
'In the advanced game, an occupant receives two additional spice for Arrakeen, two for Carthag and one for Tuek’s Sietch. These payments stack and are separate from spice gathered by forces.',
|
||||
'For each shared desert pool, Ecaz can propose a split. The other ally may accept or counter; either decision owner may instead choose an equal split, with any odd spice going to Ecaz’s ally. Proposals remain private to the pair until settlement. The table cannot advance until every pool is allocated.',
|
||||
'Advanced Ecaz Collection gives both allies their full bank income in co-occupied Arrakeen, Carthag and Tuek’s Sietch. Karama prevents only Ecaz’s income from those shared strongholds; the ally’s income, Ecaz’s other stronghold income and desert allocation remain unchanged.',
|
||||
'Review the table chronicle and your spice, then mark yourself ready.',
|
||||
],
|
||||
[
|
||||
@@ -205,6 +210,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'automatic-decisions',
|
||||
'ai-pacing',
|
||||
'ecaz-modules',
|
||||
'homeworlds',
|
||||
'advanced-combat',
|
||||
],
|
||||
},
|
||||
@@ -1040,7 +1046,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'End-of-Revival placement, eight random entry effects and direct Ecaz Duke acquisition are supported in development fixtures, with public token guides and private inspection history.',
|
||||
'End-of-Revival placement, eight random entry effects, direct Ecaz Duke acquisition and consensual alliance formation are supported in development fixtures, with public token guides and private inspection history.',
|
||||
steps: [
|
||||
'Ecaz has its reusable Ambassador plus five randomly selected Ambassadors from the other ten identities. Inspect the public supply, placed tokens, triggered group and unused pool to read each effect.',
|
||||
'Placed Ambassadors are face up by designer clarification. This implementation also shows the unplaced supply openly by default; that supply policy is an interpretation, not an explicit official FAQ ruling.',
|
||||
@@ -1048,8 +1054,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The current closing order is CHOAM market, deferred technology income, then Ecaz placement. Income received at that point can fund placement. This combined order is an implementation choice, not a priority established by a combined official FAQ.',
|
||||
'The first placement costs one spice, the second two, and so on that turn. A declaration opens a Karama response before spice is paid or the token moves. Canceling stops the remaining placement opportunity for that turn; previous placements and payments remain.',
|
||||
'Finish Ambassador placement advances the phase. On a later turn the price begins at one again and the earlier placement block expires. Pending declarations survive reload without charging twice.',
|
||||
'The custody model returns destroyed tokens to supply and returns Ecaz after its trigger. Supported entry effects set regular tokens aside, permanently remove Bene Gesserit after its trigger, and replenish only after all five random tokens have triggered. Storm crossings return tokens from exposed Arrakeen and Carthag after the Shield Wall is destroyed. A Lasgun–shield explosion returns a token only if the explosion actually resolves; an overriding traitor victory leaves it placed. Destruction does not count toward the five triggers. The reusable Ecaz Ambassador can acquire Duke directly for Ecaz; its alliance and loan alternatives remain unfinished.',
|
||||
'When an eligible entrant reaches the reusable Ecaz token, Ecaz may choose Acquire Duke Vidal for Ecaz if the shared disc is available. The token immediately returns to supply, its five random companions are unchanged, and the entrant resumes. Ecaz keeps an unused Duke across turns until battle use or Moritani acquisition. Already controlling him, assigning this effect to an ally, alliance formation, loans and Advanced Harkonnen custody remain visibly unavailable while their interactions are unfinished.',
|
||||
'The custody model returns destroyed tokens to supply and returns Ecaz after its trigger. Supported entry effects set regular tokens aside, permanently remove Bene Gesserit after its trigger, and replenish only after all five random tokens have triggered. Storm crossings return tokens from exposed Arrakeen and Carthag after the Shield Wall is destroyed. A Lasgun–shield explosion returns a token only if the explosion actually resolves; an overriding traitor victory leaves it placed. Destruction does not count toward the five triggers. The reusable Ecaz Ambassador can acquire Duke directly for Ecaz; it also offers a consensual alliance, while Duke loans remain unfinished.',
|
||||
'When an eligible entrant reaches the reusable Ecaz token, Ecaz may choose Acquire Duke Vidal for Ecaz if the shared disc is available. The token immediately returns to supply, its five random companions are unchanged, and the entrant resumes. Ecaz keeps an unused Duke across turns until battle use or Moritani acquisition. Already controlling him, assigning this effect to an ally, loans and Advanced Harkonnen custody remain visibly unavailable while their interactions are unfinished.',
|
||||
'Instead of acquiring Duke, unallied Ecaz can offer an alliance to an unallied entrant. The entrant accepts or refuses, then resumes its remaining actions. The triggered token returns to supply in either case. Acceptance activates both factions’ alliance abilities immediately and leaves Duke unchanged. An unavailable Duke does not prevent this alliance choice. Duke loans remain unfinished pending their custody ruling.',
|
||||
'When another eligible faction enters a marked stronghold, Ecaz may trigger its Ambassador for itself or its current ally, or leave the token in place. Ecaz itself, its ally, advisors and the faction matching the physical Ambassador do not trigger it. Entries with competing arrival reactions remain blocked until their ordering is implemented; a rejected entry does not spend or move its pieces.',
|
||||
'Emperor grants five bank spice. Atreides records the entrant’s current Treachery hand privately for the beneficiary; Harkonnen records one randomly selected held Traitor identity, or Face Dancer identity for a Tleilaxu entrant. These snapshots remain in the recipient’s private inspection history, identify when they were taken, and do not become a live view of later changes. Opening, enlarging or closing them needs no game acknowledgement.',
|
||||
'CHOAM lets the beneficiary choose any available hand cards, including none, for three spice each. Ixians require one available card to discard before drawing its replacement. Card controls respect projected reservations. After committing an Ixian exchange, a voluntary interruption must leave a card available for its required discard; a queued Truthtrance counts as already committed. A Bene Gesserit trigger permanently removes its token, then the beneficiary chooses an available effect outside the original supply for this group; unsupported copies remain visibly blocked.',
|
||||
@@ -1059,38 +1066,38 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
|
||||
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
|
||||
'No-Field substitution into the free four-force grant awaits the user’s interpretation and is unavailable. Simultaneous Intrusion/Terror and Ambassador/Terror combinations remain blocked. Moritani Atomics Aftermath, Homeworlds and Discovery locations require their still-gated producers or modules; this checkpoint does not claim those combinations.',
|
||||
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition through the reusable Ecaz token. The remaining effects, Occupy, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
|
||||
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. The remaining effects, combined Occupy combat, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
|
||||
],
|
||||
checklist: [
|
||||
{
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition are integrated. Storm/explosion token returns are integrated. Competing arrival ordering and remaining effects remain unfinished.',
|
||||
'Inventory, placement and eight entry effects plus their Bene Gesserit copies and direct Ecaz Duke acquisition or consensual alliance are integrated. Storm/explosion token returns are integrated. Competing arrival ordering and remaining effects remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Placement and entry controls show valid destinations, beneficiaries, copies and card choices with blocked reasons. Relocation controls select physical and elite forces by source sector, concealed markers and eligible advisor flips. Independent Guild shipment controls select typed reserve counts, destination sector or zero; BG accompaniment controls show source-specific blocked reasons. Public token guides and recipient-only historical card/identity inspectors remain available without acknowledgement.',
|
||||
'Placement and entry controls show valid destinations, beneficiaries, copies and card choices with blocked reasons. Ecaz can offer an alliance to an eligible entrant, whose separate accept/refuse controls preserve decision ownership. Relocation controls select physical and elite forces by source sector, concealed markers and eligible advisor flips. Independent Guild shipment controls select typed reserve counts, destination sector or zero; BG accompaniment controls show source-specific blocked reasons. Public token guides and recipient-only historical card/identity inspectors remain available without acknowledgement.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All four profiles handle placement, supported trigger/beneficiary choices, copied effects, legal CHOAM/Ixian card selection, Fremen relocation, Guild reserve shipment and its BG accompaniment from their own projections. Unsupported effects are declined; remaining effect policies are unfinished.',
|
||||
'All four profiles handle placement, supported trigger/beneficiary choices, copied effects, legal CHOAM/Ixian card selection, Fremen relocation, Guild reserve shipment and its BG accompaniment from their own projections. They prefer an available Ecaz alliance offer and accept a valid owned reply; direct Duke acquisition remains the fallback when a new alliance is unavailable. Unsupported effects are declined; remaining effect policies are unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Placement, supported entry decisions, private historical inspections, inventory lifecycle and all eleven effect descriptions are available. Complete timing and interaction coverage remains open.',
|
||||
'Placement, supported entry decisions, private historical inspections, inventory lifecycle and all eleven effect descriptions are available. The Ecaz guide distinguishes consent, token commitment and Duke loans awaiting a custody ruling. Complete timing and interaction coverage remains open.',
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Focused tests cover custody, placement payment, entry continuation, private snapshots, effect choices, destruction and all four bot profiles. Persisted tests cover concurrent triggers, private snapshot restoration, and stale-event rejection; browser checks verified entry and enlarged private cards. Guild-focused tests cover physical quotes, historical continuations, cancellation, resume provenance, actual engine journeys and all four AI profiles. Guild multiplayer and browser verification are pending at this documentation checkpoint. Complete expansion games and competing arrivals remain.',
|
||||
'Focused tests cover custody, placement payment, entry continuation, private snapshots, effect choices, destruction and all four bot profiles. Persisted tests cover concurrent triggers, private snapshot restoration, and stale-event rejection; browser checks verified entry and enlarged private cards. Guild-focused tests cover physical quotes, historical continuations, cancellation, resume provenance, actual engine journeys and all four AI profiles. Guild multiplayer and browser verification are pending at this documentation checkpoint. Ecaz alliance tests cover both consent outcomes, real arrival and worm continuations, card suspension, private-state preservation and racing replies; browser checks confirm keyboard proposal and AI acceptance. Complete expansion games and competing arrivals remain.',
|
||||
evidence: [
|
||||
'tests/ecaz-ambassadors.test.ts',
|
||||
'tests/ecaz-placement.test.ts',
|
||||
@@ -1098,6 +1105,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/ecaz-duke-acquisition.test.ts',
|
||||
'tests/ecaz-duke-bots.test.ts',
|
||||
'tests/ecaz-duke-recovery.test.ts',
|
||||
'tests/ecaz-alliance.test.ts',
|
||||
'tests/ecaz-alliance-engine.test.ts',
|
||||
'tests/ecaz-alliance-recovery.test.ts',
|
||||
'tests/ecaz-entry-bots.test.ts',
|
||||
'tests/ecaz-entry-continuations.test.ts',
|
||||
'tests/ecaz-entry-recovery.test.ts',
|
||||
@@ -2202,7 +2212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Begin with six forces in the Imperial Basin, fourteen in reserves and twelve spice. Two force revivals are free.',
|
||||
'Your Ambassador supply contains the Ecaz token and five random tokens from the remaining pool. Inspect the linked Ambassador guide for all eleven identities and their custody rules.',
|
||||
'End-of-Revival placement has owner controls, escalating prices, cancellation responses and bot support in development fixtures. Full faction starts remain disabled.',
|
||||
'Eight entry effects and their Bene Gesserit copies, plus direct Ecaz Duke acquisition, have focused support. Storm/explosion token returns, ordinary territorial co-occupation and the three jointly occupied stronghold victory are also supported. Tleilaxu revival, combined Occupy battles, shared desert collection, the Fremen endgame exception and the complete Duke revival and custody interactions remain unfinished. Consult the feature checklist for the exact tested boundary.',
|
||||
'Eight entry effects and their Bene Gesserit copies, plus direct Ecaz Duke acquisition, have focused support. Storm/explosion token returns, ordinary territorial co-occupation and the three jointly occupied stronghold victory are also supported. Shared desert allocation and Advanced Collection income, including cancellation, are also supported in development fixtures. Tleilaxu revival, combined Occupy battles, the Fremen endgame exception and the complete Duke revival and custody interactions remain unfinished. Consult the feature checklist for the exact tested boundary.',
|
||||
]
|
||||
: f.id === 'moritani'
|
||||
? [
|
||||
@@ -2270,7 +2280,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.',
|
||||
'Ecaz gains Vidal through its Ecaz Ambassador if he is neither in the Tanks, captured nor a ghola. Ecaz keeps him until battle use or Moritani takes him. The Ambassador can instead form a consensual alliance when both factions are unallied; Ecaz may then lend Vidal to that new ally for the turn.',
|
||||
'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.',
|
||||
'The current implementation supports Moritani acquisition, direct Ecaz Ambassador self-acquisition and one-battle use. Ecaz alliance/loan alternatives, reacquisition while already controlled, revival and captured-leader interactions remain unfinished. This shared component does not enable complete Ecaz or Moritani games. Inspection does not commit a battle plan.',
|
||||
'The current implementation supports Moritani acquisition, direct Ecaz Ambassador self-acquisition, consensual Ambassador alliances and one-battle use. Duke loans, reacquisition while already controlled, revival and captured-leader interactions remain unfinished. This shared component does not enable complete Ecaz or Moritani games. Inspection does not commit a battle plan.',
|
||||
],
|
||||
related: [
|
||||
'faction-ecaz',
|
||||
@@ -3086,6 +3096,217 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
|
||||
},
|
||||
{
|
||||
id: 'homeworlds',
|
||||
title: 'Homeworlds: population and occupation',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Inspect all thirteen Homeworld cards, their population faces and occupied effects. Gameplay integration is in development.',
|
||||
searchText: HOMEWORLD_CARDS.map((card) =>
|
||||
[
|
||||
card.name,
|
||||
...card.high.gameplay,
|
||||
...card.low.gameplay,
|
||||
...card.occupied.gameplay,
|
||||
].join(' '),
|
||||
).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.',
|
||||
'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.',
|
||||
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
|
||||
'Quote a group of normal revivals using population before that group returns. A later group uses the resulting population: a low Fremen group can return four free forces, but returning a smaller group that restores high population can remove the fourth allowance. La La La prevents the full free rate.',
|
||||
'When Southern Hemisphere begins a supported revival at high population, Fremen may place the newly revived starred forces together in one territory already containing their forces, or leave them in reserves. This includes supported ordinary, Emperor-funded and Ghola revivals. Basic keeps the starred counters; Advanced still limits Fedaykin revival to one per turn.',
|
||||
'When Tleilax begins ordinary Free Revival at high population, Tleilaxu may send the newly revived free group together to one permitted territory or Homeworld, or leave it in reserves. Paid counters in the same return and Ghola returns do not join this group. An allied Homeworld is forbidden. Placement costs no additional spice and uses no ordinary shipment or movement allowance.',
|
||||
'The revival placement choice shows unavailable destinations. A return that first restores high population, partial groups, split destinations, Southern storm placement and entry effects requiring a shipment classification remain guarded while their rules are unresolved. Independent revival income waits until placement and supported Ambassador reactions finish; refreshing does not repeat the return or payment.',
|
||||
'While Tupile has at least eleven native forces, CHOAM cannot discard Worthless cards for spice through either ordinary sale price or a CHOAM Ambassador. Non-Worthless duplicate sales, allied trades and the Advanced CHOAM Karama cash-in retain their own rules. If a Ghola revival raises Tupile before a declared sale settles, that Worthless card stays in hand and earns no spice.',
|
||||
'At the end of Bidding, high-population Kaitain lets Emperor pay two spice per selected Treachery Card to discard it. This does not play the card or begin another auction. Emperor and CHOAM share the closing opportunity, so sales, allied trades and paid discards can occur in either order. Current hands, spice and native population determine each action. Finish when ready; a new action clears readiness.',
|
||||
'In supported payment cases, low Kaitain halves Emperor’s Treachery payment income and low Junction halves eligible Guild shipping income, rounded up. The payer still pays the full cost. For example, a five-spice auction payment gives low-population Emperor three spice and leaves two in the bank. The income opportunity shows the original payment, current receipt and bank remainder. A population change before collection updates the split; ordinary Karama can still prevent the separate faction income.',
|
||||
'Guild’s own shipment contribution goes to the bank before any low-Junction reduction. Splits with two odd contributions from other factions remain unavailable while their rounding rule is unresolved; choose another funding split. Single-contributor payments and splits where both rounding interpretations agree remain supported. Occupier income is still unfinished.',
|
||||
'With at least seven native forces on Giedi Prime, Harkonnen automatically gains two bank spice after collecting positive desert spice during Spice Collection, once for the whole phase. Several deposits still earn only two. Shared Ecaz spice qualifies when Harkonnen actually receives a positive allocation; a zero share, stronghold income or technology income does not qualify. Refreshing preserves the completed bonus.',
|
||||
'With fewer than eight native forces on Grumman, Moritani can reveal Terror only when at least three forces enter together. Count each incoming physical counter once, including advisors, and a concealed No-Field as one regardless of its hidden value. Forces already at the destination do not count. Grumman’s high-population Collection token action remains unfinished.',
|
||||
'While Ecaz has at least seven native reserves, it automatically receives three bank spice for each discarded poison weapon, including its own. Poison Blade and Poison Tooth qualify; Chemistry qualifies only when used as a weapon. Poison defenses and Residual Poison do not qualify. Ecaz sees its income in a private Homeworld record; private discarded faces stay private.',
|
||||
'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.',
|
||||
'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.',
|
||||
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
|
||||
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
|
||||
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
|
||||
'Low Tupile’s intelligence advantage requires contact with another faction’s Homeworld or that faction’s presence on Tupile, and is usable once per faction. Its public eligibility checks are implemented, but the private disclosure action and occupation-dependent availability remain unfinished.',
|
||||
'While Junction has high population, Guild may offer another faction transport at half or full price during that faction’s shipment. The recipient can choose exact forces from one territory or Homeworld, including a return to their own world. An allied Homeworld remains forbidden. Half-price totals round up; five forces to a Homeworld cost three spice. The Junction route and offered rate resist Karama.',
|
||||
'Homeworld shipment, battle and occupation have special rules. Occupiers receive the printed Collection income and card effects. An alliance can gain a special Homeworld victory through high-population Ecaz; this is separate from ordinary stronghold victory.',
|
||||
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
||||
],
|
||||
example:
|
||||
'Atreides with six reserves on Caladan uses the high face. Dropping to five uses the low face and removes the next-Spice-Blow inspection advantage. Either face adds two to native battle strength on Caladan.',
|
||||
related: [
|
||||
'ecaz-modules',
|
||||
'setup',
|
||||
'revival',
|
||||
'movement',
|
||||
'battle',
|
||||
'victory',
|
||||
],
|
||||
checklist: [
|
||||
{
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Concealed transport, occupation and remaining card effects are unfinished; game starts stay disabled.',
|
||||
evidence: [
|
||||
'game/homeworld-cards.ts',
|
||||
'game/homeworld-game.ts',
|
||||
'game/homeworld-native-reserves.ts',
|
||||
'game/homeworld-revival-deployment.ts',
|
||||
'game/homeworld-revival-destinations.ts',
|
||||
'game/homeworld-revival-return.ts',
|
||||
'game/homeworld-victory-reinforcement.ts',
|
||||
'game/homeworld-victory-return.ts',
|
||||
'game/homeworld-arrival.ts',
|
||||
'game/tupile-intelligence.ts',
|
||||
'game/homeworld-shipment.ts',
|
||||
'game/guild-homeworld-shipment.ts',
|
||||
'game/junction-transport.ts',
|
||||
'game/junction-offer.ts',
|
||||
'game/homeworld-alliance.ts',
|
||||
'game/homeworld-benefits.ts',
|
||||
'game/homeworld-mobility.ts',
|
||||
'game/homeworld-card-economy.ts',
|
||||
'game/homeworld-payment-income.ts',
|
||||
'game/homeworld-collection.ts',
|
||||
'game/giedi-collection.ts',
|
||||
'game/terror-entry-receipt.ts',
|
||||
'game/bidding-end.ts',
|
||||
'game/ecaz-poison-income.ts',
|
||||
'game/choam-market-ghola.ts',
|
||||
'game/charity.ts',
|
||||
],
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Card inspection is available in the reference. Development tables display native reserves, explicit Imperial shipment sources, world-to-world shipment and Guild transport from Arrakis with costs and pledged funding, Guild interception, optional Junction half/full offers and typed sponsored transport, transfers between Kaitain and Salusa, charity payment sources, current revival allowances, population-based movement restrictions and one/two-advisor choices, native battle bonuses, Homeworld casualties, replacement controls, Tupile sale explanations, shared end-of-Bidding actions, Ecaz’s private poison-income record, current low-population payment splits, automatic Giedi collection history, count-dependent Ambassador destinations and the optional revived-group placement; starting this module remains unavailable.',
|
||||
evidence: [
|
||||
'components/homeworld-cards.tsx',
|
||||
'components/homeworld-table.tsx',
|
||||
'components/homeworld-revival-deployment.tsx',
|
||||
'components/caladan-reinforcement.tsx',
|
||||
'components/homeworld-shipment.tsx',
|
||||
'components/guild-homeworld-shipment.tsx',
|
||||
'components/junction-transport.tsx',
|
||||
'components/bidding-end.tsx',
|
||||
'components/ecaz-poison-income.tsx',
|
||||
],
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Shared public choices supply native shipment counters, Imperial transfers and current low revival allowances. All four profiles use Salusa’s per-type support legality and respect unresolved special-Karama and mid-battle Ghola timing boundaries. All four profiles handle world-to-world shipment, Guild transport from Arrakis, native returns and interception, Homeworld battle choices, typed losses, late defense and supported replacement actions. All four profiles can offer and use Junction transport through public quotes, respect population-based movement restrictions and choose legal Spiritual Advisor quantities. Market choices exclude high-Tupile Worthless sales. All four profiles use the shared closing opportunity for CHOAM actions, affordable Kaitain discards and readiness. Ecaz income needs no separate claim. Concealed transport, occupation and remaining card-effect strategies are unfinished.',
|
||||
evidence: ['game/homeworld-options.ts', 'game/homeworld-shipment-options.ts', 'game/guild-homeworld-shipment-options.ts', 'game/junction-transport-options.ts'],
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All 26 faces, global rules and source discrepancies are documented. Imperial shipment and movement controls explain sources, costs and unavailable actions. Card-economy notes explain shared closing actions, private poison income, winner-discard ordering and low-population payment splits. Divergent allied rounding and occupation entitlement remain unresolved; full interaction guidance remains in progress.',
|
||||
evidence: [
|
||||
'docs/HOMEWORLD_COMPONENT_AUDIT.md',
|
||||
'docs/HOMEWORLD_RULES.md',
|
||||
'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md',
|
||||
'docs/HOMEWORLD_BENEFITS_RULES.md',
|
||||
'docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md',
|
||||
'docs/HOMEWORLD_REVIVAL_DEPLOYMENT_INTEGRATION.md',
|
||||
'docs/HOMEWORLD_REVIVAL_DEPLOYMENT_RUNTIME.md',
|
||||
'docs/HOMEWORLD_COMBAT_IMPLEMENTATION.md',
|
||||
'docs/HOMEWORLD_BATTLE_RUNTIME.md',
|
||||
'docs/HOMEWORLD_SHIPMENT_RUNTIME.md',
|
||||
'docs/GUILD_HOMEWORLD_TRANSPORT_RUNTIME.md',
|
||||
'docs/JUNCTION_TRANSPORT_RULES.md',
|
||||
'docs/JUNCTION_TRANSPORT_RUNTIME.md',
|
||||
'docs/HOMEWORLD_MOBILITY_RULES.md',
|
||||
'docs/HOMEWORLD_MOBILITY_RUNTIME.md',
|
||||
'docs/HOMEWORLD_CARD_ECONOMY_RULES.md',
|
||||
'docs/HOMEWORLD_CARD_ECONOMY_RUNTIME.md',
|
||||
'docs/HOMEWORLD_PAYMENT_INCOME_RULES.md',
|
||||
'docs/HOMEWORLD_PAYMENT_INCOME_RUNTIME.md',
|
||||
'docs/HOMEWORLD_COLLECTION_RULES.md',
|
||||
'docs/HOMEWORLD_COLLECTION_INTEGRATION.md',
|
||||
'docs/HOMEWORLD_COLLECTION_RUNTIME.md',
|
||||
'docs/ECAZ_POISON_INCOME_RULES.md',
|
||||
],
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Tests cover genuine setup with implemented decks, Basic special-counter identity, actual transfer/shipment/revival, saved-state rejection and competing database submissions. Complete Homeworld games and module combinations are not yet verified.',
|
||||
evidence: [
|
||||
'tests/homeworld-custody.test.ts',
|
||||
'tests/homeworld-cards.test.ts',
|
||||
'tests/homeworld-payment-income.test.ts',
|
||||
'tests/homeworld-payment-income-engine.test.ts',
|
||||
'tests/homeworld-payment-income-recovery.test.ts',
|
||||
'tests/homeworld-collection.test.ts',
|
||||
'tests/homeworld-collection-engine.test.ts',
|
||||
'tests/homeworld-collection-recovery.test.ts',
|
||||
'tests/homeworld-grumman-engine.test.ts',
|
||||
'tests/homeworld-grumman-recovery.test.ts',
|
||||
'tests/homeworld-setup-engine.test.ts',
|
||||
'tests/homeworld-actions-engine.test.ts',
|
||||
'tests/homeworld-basic-counters.test.ts',
|
||||
'tests/homeworld-salusa-engine.test.ts',
|
||||
'tests/homeworld-combat.test.ts',
|
||||
'tests/homeworld-battle-resolution.test.ts',
|
||||
'tests/homeworld-battle-engine.test.ts',
|
||||
'tests/homeworld-battle-recovery.test.ts',
|
||||
'tests/homeworld-shipment-engine.test.ts',
|
||||
'tests/homeworld-shipment-recovery.test.ts',
|
||||
'tests/homeworld-shipment-controls.test.ts',
|
||||
'tests/homeworld-shipment-review.test.ts',
|
||||
'tests/guild-homeworld-shipment-engine.test.ts',
|
||||
'tests/guild-homeworld-shipment-recovery.test.ts',
|
||||
'tests/guild-homeworld-shipment-controls.test.ts',
|
||||
'tests/junction-transport.test.ts',
|
||||
'tests/junction-transport-engine.test.ts',
|
||||
'tests/junction-transport-recovery.test.ts',
|
||||
'tests/junction-transport-controls.test.ts',
|
||||
'tests/junction-transport-review.test.ts',
|
||||
'tests/homeworld-card-economy.test.ts',
|
||||
'tests/homeworld-tupile-sales-engine.test.ts',
|
||||
'tests/homeworld-tupile-sales-recovery.test.ts',
|
||||
'tests/bidding-end-engine.test.ts',
|
||||
'tests/bidding-end-controls.test.ts',
|
||||
'tests/bidding-end-recovery.test.ts',
|
||||
'tests/ecaz-poison-income.test.ts',
|
||||
'tests/ecaz-poison-income-engine.test.ts',
|
||||
'tests/homeworld-mobility.test.ts',
|
||||
'tests/homeworld-mobility-engine.test.ts',
|
||||
'tests/homeworld-mobility-recovery.test.ts',
|
||||
'tests/homeworld-spiritual-advisors.test.ts',
|
||||
'tests/homeworld-spiritual-advisors-recovery.test.ts',
|
||||
'tests/homeworld-advisor-ambassador.test.ts',
|
||||
'tests/homeworld-replacement-engine.test.ts',
|
||||
'tests/homeworld-recovery.test.ts',
|
||||
'tests/homeworld-charity-engine.test.ts',
|
||||
'tests/homeworld-revival-benefits-engine.test.ts',
|
||||
'tests/homeworld-revival-deployment.test.ts',
|
||||
'tests/homeworld-revival-return.test.ts',
|
||||
'tests/homeworld-revival-destinations.test.ts',
|
||||
'tests/homeworld-revival-deployment-engine.test.ts',
|
||||
'tests/homeworld-revival-deployment-controls.test.ts',
|
||||
'tests/homeworld-revival-deployment-recovery.test.ts',
|
||||
'tests/homeworld-victory-reinforcement.test.ts',
|
||||
'tests/caladan-reinforcement-controls.test.ts',
|
||||
'tests/homeworld-victory-reinforcement-recovery.test.ts',
|
||||
'tests/tupile-intelligence.test.ts',
|
||||
'tests/homeworld-benefits-recovery.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...[
|
||||
[
|
||||
'ix-modules',
|
||||
@@ -3138,6 +3359,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'faction-moritani',
|
||||
'moritani-terror',
|
||||
'ecaz-ambassadors',
|
||||
'homeworlds',
|
||||
'implementation-checklist',
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -3,7 +3,8 @@ import { isAuditorLeader } from './choam-auditor';
|
||||
import { controlsLeader } from './leader-control';
|
||||
import {
|
||||
eliteRevivalRemaining,
|
||||
forceRevivalLimit,
|
||||
normalForceRevivalLimit,
|
||||
freeRevivalRate,
|
||||
forceRevivalQuote,
|
||||
forceRevivalRemaining,
|
||||
newRevivalRules,
|
||||
@@ -23,6 +24,7 @@ export type RevivalCancellationContext = Pick<
|
||||
| 'phase'
|
||||
| 'turn'
|
||||
| 'advanced'
|
||||
| 'homeworlds'
|
||||
| 'players'
|
||||
| 'revivalRules'
|
||||
| 'freeRevival'
|
||||
@@ -98,7 +100,7 @@ export function continuationCustody(
|
||||
eliteTanks <= p.tanks &&
|
||||
elite <= eliteTanks &&
|
||||
n - elite <= p.tanks - eliteTanks &&
|
||||
elite <= eliteRevivalRemaining(p),
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
'The pending ordinary and elite revival groups no longer match their tanks.',
|
||||
);
|
||||
return;
|
||||
@@ -283,11 +285,12 @@ export function quoteRevivalCancellation(
|
||||
}
|
||||
if (kind === 'choamRevival') {
|
||||
rules.choamBlocked = true;
|
||||
if (p.revived + pending.amount! > forceRevivalLimit(context, p))
|
||||
if (p.revived + pending.amount! > normalForceRevivalLimit(context, p))
|
||||
return { rules, pending: null, outcome: 'abandoned' };
|
||||
Object.assign(pending, forceRevivalQuote(context, p, pending.amount!));
|
||||
pending.checks = [];
|
||||
if (p.revived + pending.amount! > 3) pending.checks.push('revivalLimit');
|
||||
if (p.revived + pending.amount! > Math.max(3, freeRevivalRate(context, p)))
|
||||
pending.checks.push('revivalLimit');
|
||||
if (pending.cost < pending.normalCost)
|
||||
pending.checks.push('revivalDiscount');
|
||||
} else {
|
||||
|
||||
+18
-2
@@ -1,6 +1,6 @@
|
||||
import type { Decision, Game, ResponseWindow } from './engine';
|
||||
import type { PendingRevival } from './revival';
|
||||
import { forceRevivalRemaining } from './revival';
|
||||
import { forceRevivalRemaining, freeRevivalRemaining } from './revival';
|
||||
import {
|
||||
continuationCustody,
|
||||
RevivalCancellationError,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { controlsLeader } from './leader-control';
|
||||
import { TECH_TOKENS, ownedTech } from './tech-tokens';
|
||||
import { tleilaxuHomeworldFreeIncomeBlocked } from './homeworld-benefits';
|
||||
|
||||
export type RevivalResumeContext = Pick<
|
||||
Game,
|
||||
@@ -15,6 +16,8 @@ export type RevivalResumeContext = Pick<
|
||||
| 'phase'
|
||||
| 'turn'
|
||||
| 'advanced'
|
||||
| 'homeworlds'
|
||||
| 'homeworldRevival'
|
||||
| 'players'
|
||||
| 'pendingRevival'
|
||||
| 'revivalRules'
|
||||
@@ -112,6 +115,16 @@ export function quoteRevivalResume(
|
||||
'The pending force revival has invalid physical counts.',
|
||||
);
|
||||
const paid = original.amount - original.free;
|
||||
// Supported pending-revival overlays cannot move native forces or consume
|
||||
// ordinary revival usage. Validate the accepted group's free allocation
|
||||
// before settlement crosses a population threshold. A later independent
|
||||
// request gets the rate at its own declaration.
|
||||
if (g.homeworlds && !original.emperorExtra)
|
||||
requireResume(
|
||||
original.free ===
|
||||
Math.min(original.amount, freeRevivalRemaining(g, p)),
|
||||
'The saved Homeworld revival free allocation does not match its current eligible group.',
|
||||
);
|
||||
const elite = original.elite ?? 0;
|
||||
const freeCyborgs = paid * 2 + elite - original.normalCost;
|
||||
requireResume(
|
||||
@@ -342,7 +355,10 @@ export function quoteRevivalResume(
|
||||
'The revival free-income receipt is invalid.',
|
||||
);
|
||||
const free = pending.free > 0;
|
||||
const reward = free && lastFree !== g.turn;
|
||||
const reward =
|
||||
free &&
|
||||
lastFree !== g.turn &&
|
||||
!(payer.id !== tleilaxu.id && tleilaxuHomeworldFreeIncomeBlocked(g));
|
||||
if (free) quote.freeIncome = { player: payer.id, turn: g.turn };
|
||||
const amount =
|
||||
(payer.id === tleilaxu.id ? 0 : pending.cost) + (reward ? 1 : 0);
|
||||
|
||||
+32
-13
@@ -1,6 +1,11 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { faction, type FactionId } from './catalog';
|
||||
import { isAuditorLeader } from './choam-auditor';
|
||||
import { homeworldLowBonus } from './homeworld-benefits';
|
||||
type RevivalRateContext = Pick<
|
||||
Game,
|
||||
'advanced' | 'players' | 'homeworlds' | 'revivalRules' | 'freeRevival'
|
||||
>;
|
||||
export type RevivalRules = {
|
||||
expanded: string[];
|
||||
choamBlocked?: boolean;
|
||||
@@ -39,20 +44,31 @@ export function revivalDiscount(
|
||||
g.players.some((t) => t.faction === 'tleilaxu' && t.ally === p.id)
|
||||
);
|
||||
}
|
||||
export function freeRevivalRemaining(
|
||||
g: Pick<Game, 'revivalRules' | 'freeRevival'>,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived'>,
|
||||
export function freeRevivalRate(
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction'>,
|
||||
) {
|
||||
return g.revivalRules?.freeBlocked?.includes(p.id)
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
(g.freeRevival.includes(p.id) ? 3 : faction(p.faction).revival) -
|
||||
p.revived,
|
||||
);
|
||||
: (g.freeRevival.includes(p.id) ? 3 : faction(p.faction).revival) +
|
||||
homeworldLowBonus(g, p.id);
|
||||
}
|
||||
/** E3's explicit four-free Fremen rate may exceed the ordinary three-return cap;
|
||||
* the extra free rate never creates an additional paid allowance. */
|
||||
export function normalForceRevivalLimit(
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction'>,
|
||||
) {
|
||||
return Math.max(forceRevivalLimit(g, p), freeRevivalRate(g, p));
|
||||
}
|
||||
export function freeRevivalRemaining(
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived'>,
|
||||
) {
|
||||
return Math.max(0, freeRevivalRate(g, p) - p.revived);
|
||||
}
|
||||
export function forceRevivalQuote(
|
||||
g: Pick<Game, 'players' | 'revivalRules' | 'freeRevival'>,
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived'>,
|
||||
amount: number,
|
||||
elite = 0,
|
||||
@@ -72,14 +88,14 @@ export function forceRevivalQuote(
|
||||
|
||||
/** Normal force returns only; Emperor-funded extra revivals and card effects are separate. */
|
||||
export function forceRevivalRemaining(
|
||||
g: Pick<Game, 'revivalRules' | 'freeRevival' | 'players'>,
|
||||
g: RevivalRateContext,
|
||||
p: Pick<Player, 'id' | 'faction' | 'revived' | 'tanks'>,
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
p.tanks,
|
||||
forceRevivalLimit(g, p) - p.revived,
|
||||
normalForceRevivalLimit(g, p) - p.revived,
|
||||
// GF9 November 2020 FAQ p. 5 permits paid Fremen revivals with Tleilaxu in play.
|
||||
// Selecting expansion cards alone does not provide that faction's permission.
|
||||
p.faction === 'fremen' &&
|
||||
@@ -179,8 +195,11 @@ export function revivalPrevented(
|
||||
);
|
||||
}
|
||||
|
||||
export function eliteRevivalRemaining(p: Pick<Player, 'faction' | 'elites'>) {
|
||||
return p.faction === 'ixians'
|
||||
export function eliteRevivalRemaining(
|
||||
p: Pick<Player, 'faction' | 'elites'>,
|
||||
advanced = true,
|
||||
) {
|
||||
return !advanced || p.faction === 'ixians'
|
||||
? (p.elites?.tanks ?? 0)
|
||||
: Math.min(p.elites?.tanks ?? 0, Math.max(0, 1 - (p.elites?.revived ?? 0)));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ const DECISIONS = {
|
||||
moritaniPlacement: true,
|
||||
ecazPlacement: true,
|
||||
ecazSpice: true,
|
||||
homeworldRevivalDeployment: true,
|
||||
caladanReinforcement: true,
|
||||
choamStorm: true,
|
||||
choamMovement: true,
|
||||
choamMentat: true,
|
||||
@@ -41,9 +43,12 @@ const DECISIONS = {
|
||||
fullPlanOffer: true,
|
||||
fullPlanRead: true,
|
||||
guildShipment: true,
|
||||
homeworldShipmentGuild: true,
|
||||
nullentropy: true,
|
||||
handExchange: true,
|
||||
battleLosses: true,
|
||||
homeworldExplosion: true,
|
||||
homeworldDefense: true,
|
||||
captureOffer: true,
|
||||
capturedLeader: true,
|
||||
guildTiming: true,
|
||||
|
||||
+23
-54
@@ -1,4 +1,8 @@
|
||||
import { casualtyOptions, type CombatForces } from './combat';
|
||||
import {
|
||||
casualtyOptions,
|
||||
validCombatForces,
|
||||
type CombatForces,
|
||||
} from './combat';
|
||||
|
||||
export type StoneBurnerSide = 'attacker' | 'defender';
|
||||
export type StoneBurnerComparison = {
|
||||
@@ -7,28 +11,9 @@ export type StoneBurnerComparison = {
|
||||
defender: number[];
|
||||
};
|
||||
|
||||
// Current supported factions each have 20 physical tokens. This is an input/work
|
||||
// bound, not a new battle rule; extend it if a supported module changes that pool.
|
||||
const MAX_PHYSICAL_FORCES = 20;
|
||||
const opposingChoices = new Map<string, readonly (readonly number[])[]>();
|
||||
const MAX_CACHED_POOLS = 128;
|
||||
|
||||
function validForces(forces: CombatForces): boolean {
|
||||
return (
|
||||
!!forces &&
|
||||
typeof forces === 'object' &&
|
||||
!Array.isArray(forces) &&
|
||||
Number.isSafeInteger(forces.normal) &&
|
||||
forces.normal >= 0 &&
|
||||
Number.isSafeInteger(forces.elite) &&
|
||||
forces.elite >= 0 &&
|
||||
forces.normal + forces.elite <= MAX_PHYSICAL_FORCES &&
|
||||
(forces.eliteStrength === 1 || forces.eliteStrength === 2) &&
|
||||
typeof forces.freeSupport === 'boolean' &&
|
||||
(forces.normalFixedHalf === undefined ||
|
||||
typeof forces.normalFixedHalf === 'boolean')
|
||||
);
|
||||
}
|
||||
function validSide(side: StoneBurnerSide): boolean {
|
||||
return side === 'attacker' || side === 'defender';
|
||||
}
|
||||
@@ -37,7 +22,7 @@ function sorted(values: Iterable<number>): number[] {
|
||||
}
|
||||
function totals(forces: CombatForces, dial: number, support: number): number[] {
|
||||
if (
|
||||
!validForces(forces) ||
|
||||
!validCombatForces(forces) ||
|
||||
!Number.isFinite(dial) ||
|
||||
dial < 0 ||
|
||||
!Number.isInteger(dial * 2) ||
|
||||
@@ -105,41 +90,25 @@ function allOpposingTotals(
|
||||
forces.eliteStrength,
|
||||
!!forces.normalFixedHalf,
|
||||
forces.freeSupport,
|
||||
!!forces.eliteFreeSupport,
|
||||
].join(':');
|
||||
const cached = opposingChoices.get(key);
|
||||
if (cached) return cached;
|
||||
const groups = new Map<string, Set<number>>();
|
||||
const add = (halfDial: number, support: number, undialed: number) => {
|
||||
const planKey = `${halfDial}:${support}`;
|
||||
const group = groups.get(planKey) ?? new Set<number>();
|
||||
group.add(undialed);
|
||||
groups.set(planKey, group);
|
||||
};
|
||||
for (let normal = 0; normal <= forces.normal; normal++)
|
||||
for (let elite = 0; elite <= forces.elite; elite++) {
|
||||
const undialed = forces.normal + forces.elite - normal - elite;
|
||||
if (forces.freeSupport) {
|
||||
add(
|
||||
normal * (forces.normalFixedHalf ? 1 : 2) +
|
||||
elite * forces.eliteStrength * 2,
|
||||
0,
|
||||
undialed,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let paidNormal = 0;
|
||||
paidNormal <= (forces.normalFixedHalf ? 0 : normal);
|
||||
paidNormal++
|
||||
)
|
||||
for (let paidElite = 0; paidElite <= elite; paidElite++)
|
||||
add(
|
||||
normal + paidNormal + (elite + paidElite) * forces.eliteStrength,
|
||||
paidNormal + paidElite,
|
||||
undialed,
|
||||
);
|
||||
const result: number[][] = [];
|
||||
const maximumHalfDial =
|
||||
forces.normal * (forces.normalFixedHalf ? 1 : 2) +
|
||||
forces.elite * forces.eliteStrength * 2;
|
||||
const maximumSupport = forces.freeSupport
|
||||
? 0
|
||||
: (forces.normalFixedHalf ? 0 : forces.normal) +
|
||||
(forces.eliteFreeSupport ? 0 : forces.elite);
|
||||
// Use the same legality and typed-support arithmetic as sealed plans. Cache
|
||||
// public pool results so repeated preflights do not repeat this enumeration.
|
||||
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
|
||||
for (let support = 0; support <= maximumSupport; support++) {
|
||||
const choices = totals(forces, halfDial / 2, support);
|
||||
if (choices.length) result.push(choices);
|
||||
}
|
||||
const result = [...groups.values()].map(sorted);
|
||||
if (opposingChoices.size >= MAX_CACHED_POOLS)
|
||||
opposingChoices.delete(opposingChoices.keys().next().value!);
|
||||
opposingChoices.set(key, result);
|
||||
@@ -160,7 +129,7 @@ export function stoneBurnerPlanBlock(
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
if (!validForces(own) || !validForces(opponent))
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const ownTotals = totals(own, dial, support);
|
||||
if (!ownTotals.length)
|
||||
@@ -185,7 +154,7 @@ export function stoneBurnerCompulsionBlock(
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
if (!validForces(own) || !validForces(opponent))
|
||||
if (!validCombatForces(own) || !validCombatForces(opponent))
|
||||
return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.';
|
||||
const maximumStrength = own.normal + own.elite * own.eliteStrength;
|
||||
for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
|
||||
+8
-1
@@ -11,7 +11,8 @@ type TurnView = Pick<
|
||||
| 'truthtrance'
|
||||
| 'phaseOpening'
|
||||
| 'automaticContinuationPending'
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<GameView, 'biddingEnd'>>;
|
||||
|
||||
/** The single current decision owner for the table banner and seat highlight.
|
||||
* Shared response windows have no single acting seat. Only public view fields
|
||||
@@ -28,6 +29,12 @@ export function tableActionOwner(g: TurnView): string | null {
|
||||
const seated = (id: string | null | undefined) =>
|
||||
g.players.some((p) => p.id === id) ? id! : null;
|
||||
if (g.decision) return seated(g.decision.player);
|
||||
if (g.biddingEnd) {
|
||||
const pending = g.biddingEnd.owners.filter(
|
||||
(id) => !g.biddingEnd!.ready.includes(id),
|
||||
);
|
||||
return pending.length === 1 ? seated(pending[0]) : null;
|
||||
}
|
||||
const b = g.battle;
|
||||
if (!b) return seated(g.active);
|
||||
const combatants = [b.attacker, b.defender];
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
GuildAmbassadorContinuationError,
|
||||
} from './guild-ambassador-continuation';
|
||||
import type { Game, ResponseWindow } from './engine';
|
||||
import {
|
||||
homeworldMovementForesightBlock,
|
||||
homeworldSpiritualAdvisorAllowance,
|
||||
} from './homeworld-mobility';
|
||||
import {
|
||||
gameTerritories,
|
||||
location,
|
||||
@@ -64,6 +68,11 @@ export type TerminalCancellationContext = Pick<
|
||||
| 'choamCharity'
|
||||
| 'pendingAmbassador'
|
||||
| 'ecazAmbassadors'
|
||||
| 'homeworlds'
|
||||
| 'homeworldRevivalReturn'
|
||||
| 'homeworldRevivalProgress'
|
||||
| 'homeworldVictoryReinforcement'
|
||||
| 'lastBattleContext'
|
||||
>;
|
||||
const positive = (n: unknown): n is number =>
|
||||
Number.isSafeInteger(n) && (n as number) > 0;
|
||||
@@ -212,6 +221,10 @@ export function validateTerminalCancellation(
|
||||
return { kind: response.kind, owner: owner.id, known: false };
|
||||
case 'atreidesSpice':
|
||||
faction('atreides');
|
||||
requireTerminal(
|
||||
!homeworldMovementForesightBlock(g, owner.id),
|
||||
'Low-population Caladan has no Movement foresight opportunity to cancel.',
|
||||
);
|
||||
requireTerminal(
|
||||
g.phase === 5 && Array.isArray(g.spiceDeck) && g.spiceDeck.length > 0,
|
||||
'Canceled spice knowledge needs its existing lookahead opportunity.',
|
||||
@@ -254,6 +267,16 @@ export function validateTerminalCancellation(
|
||||
return { kind: response.kind, owner: owner.id };
|
||||
case 'advisor': {
|
||||
faction('beneGesserit');
|
||||
const destination = splitLocation(
|
||||
response.location ?? 'polar_sink:0',
|
||||
).territory;
|
||||
const advisorAmount = response.amount === undefined ? 1 : response.amount;
|
||||
requireTerminal(
|
||||
positive(advisorAmount) &&
|
||||
advisorAmount <=
|
||||
homeworldSpiritualAdvisorAllowance(g, owner.id, destination),
|
||||
'This spiritual advisor amount is unavailable at the current Homeworld population.',
|
||||
);
|
||||
if (response.advisorResume === 'ambassador') {
|
||||
try {
|
||||
const { order } = validateGuildAmbassadorArrivalContext(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Game } from './engine';
|
||||
import { validLocation } from './board';
|
||||
import { HomeworldCustodyError } from './homeworld-custody';
|
||||
import { TERROR_STRONGHOLDS } from './moritani-terror';
|
||||
|
||||
type TerrorEntry = NonNullable<Game['pendingTerrorEntry']>;
|
||||
|
||||
/** Original public entry facts, independent of subsequent stage or population.
|
||||
* This is a consistency receipt, not an authorization token or secret digest. */
|
||||
export function terrorEntrySignature(entry: TerrorEntry): string {
|
||||
return JSON.stringify([
|
||||
entry.token,
|
||||
entry.entrant,
|
||||
entry.territory,
|
||||
entry.sector,
|
||||
entry.amount,
|
||||
entry.elite,
|
||||
entry.cause,
|
||||
entry.turn,
|
||||
entry.phase,
|
||||
entry.resume,
|
||||
entry.ambassadorEvent ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Older saves without a receipt remain explicit legacy state. Never invent
|
||||
* their original entry count from the destination's present occupants. */
|
||||
export function validateTerrorEntrySignature(entry: TerrorEntry): void {
|
||||
if (entry.entrySignature === undefined) return;
|
||||
if (
|
||||
typeof entry.entrySignature !== 'string' ||
|
||||
typeof entry.token !== 'string' ||
|
||||
!entry.token ||
|
||||
typeof entry.entrant !== 'string' ||
|
||||
!entry.entrant ||
|
||||
!TERROR_STRONGHOLDS.includes(entry.territory) ||
|
||||
!Number.isSafeInteger(entry.sector) ||
|
||||
!validLocation(entry.territory, entry.sector) ||
|
||||
!Number.isSafeInteger(entry.amount) ||
|
||||
entry.amount < 0 ||
|
||||
!Number.isSafeInteger(entry.elite) ||
|
||||
entry.elite < 0 ||
|
||||
entry.elite > entry.amount ||
|
||||
!Number.isSafeInteger(entry.turn) ||
|
||||
entry.turn < 1 ||
|
||||
!Number.isSafeInteger(entry.phase) ||
|
||||
entry.phase < 0 ||
|
||||
entry.phase > 8 ||
|
||||
![
|
||||
'shipment',
|
||||
'movement',
|
||||
'guildTransport',
|
||||
'advisor',
|
||||
'wormRide',
|
||||
'ambassador',
|
||||
].includes(entry.cause) ||
|
||||
!['none', 'wormRide', 'ambassador'].includes(entry.resume) ||
|
||||
(entry.ambassadorEvent !== undefined &&
|
||||
(typeof entry.ambassadorEvent !== 'string' || !entry.ambassadorEvent)) ||
|
||||
entry.entrySignature !== terrorEntrySignature(entry)
|
||||
)
|
||||
throw new HomeworldCustodyError(
|
||||
'The saved Terror entry no longer matches its original public arrival receipt.',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { FactionId } from './catalog';
|
||||
import {
|
||||
homeworldForceGroups,
|
||||
HomeworldCustodyError,
|
||||
type HomeworldCustody,
|
||||
type HomeworldCustodyContext,
|
||||
} from './homeworld-custody';
|
||||
import { homeworldPopulations } from './homeworld-population';
|
||||
|
||||
/** Qualification/expiry belongs to the occupation lifecycle, not this query. */
|
||||
export type TupileOccupation = 'unoccupied' | 'occupied' | 'unknown';
|
||||
export type TupileIntelligenceCategory = 'weapons' | 'defenses';
|
||||
export type TupileIntelligenceTarget = {
|
||||
player: string;
|
||||
faction: FactionId;
|
||||
/** Public physical contact locations, including either Emperor world. */
|
||||
contact: string[];
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
/** Public eligibility only. No hand, spice, battle plan, skills or hidden
|
||||
* No-Field data is accepted or consulted. Used factions persist for the game. */
|
||||
export function tupileIntelligenceTargets(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
owner: string,
|
||||
usedFactions: readonly FactionId[],
|
||||
occupation: TupileOccupation,
|
||||
): TupileIntelligenceTarget[] {
|
||||
const worlds = homeworldForceGroups(context, custody);
|
||||
const choam = context.players.find((p) => p.id === owner);
|
||||
if (choam?.faction !== 'choam')
|
||||
throw new HomeworldCustodyError('Only the native CHOAM faction may request Tupile intelligence.');
|
||||
if (!['unoccupied', 'occupied', 'unknown'].includes(occupation))
|
||||
throw new HomeworldCustodyError('Tupile intelligence needs an explicit occupation status.');
|
||||
if (!Array.isArray(usedFactions) || new Set(usedFactions).size !== usedFactions.length ||
|
||||
usedFactions.some((faction) => faction === 'choam' || !context.players.some((p) => p.faction === faction)))
|
||||
throw new HomeworldCustodyError('Tupile intelligence usage must name unique seated opposing factions.');
|
||||
const population = homeworldPopulations(context, custody).find((p) => p.native === owner)!;
|
||||
const tupile = worlds.find((home) => home.native === owner)!;
|
||||
const present = (home: typeof tupile, player: string) => {
|
||||
const group = home.forces[player];
|
||||
return !!group && group.normal + group.elite > 0;
|
||||
};
|
||||
return context.players.filter((p) => p.id !== owner).map((target) => {
|
||||
const contact = worlds.filter((home) => home.native === target.id && present(home, owner)).map((home) => home.id);
|
||||
if (present(tupile, target.id)) contact.push(tupile.id);
|
||||
const blocked = occupation === 'unknown'
|
||||
? 'Tupile occupation must be resolved before its low advantage can be used.'
|
||||
: occupation === 'occupied'
|
||||
? 'Occupied Tupile removes CHOAM’s low-population intelligence advantage.'
|
||||
: population.side !== 'low'
|
||||
? 'Tupile intelligence requires at most ten native CHOAM reserves.'
|
||||
: usedFactions.includes(target.faction)
|
||||
? 'Tupile intelligence has already been used against this faction in this game.'
|
||||
: contact.length === 0
|
||||
? 'CHOAM must be on that faction’s Homeworld, or that faction must be on Tupile.'
|
||||
: null;
|
||||
return { player: target.id, faction: target.faction, contact, blocked };
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate a requested category without reading the answer or consuming use.
|
||||
* The eventual engine must atomically record one current private disclosure. */
|
||||
export function quoteTupileIntelligenceRequest(
|
||||
context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody,
|
||||
owner: string,
|
||||
usedFactions: readonly FactionId[],
|
||||
occupation: TupileOccupation,
|
||||
target: string,
|
||||
category: TupileIntelligenceCategory,
|
||||
) {
|
||||
if (category !== 'weapons' && category !== 'defenses')
|
||||
throw new HomeworldCustodyError('Choose either the weapon count or the defense count.');
|
||||
const candidate = tupileIntelligenceTargets(context, custody, owner, usedFactions, occupation)
|
||||
.find((p) => p.player === target);
|
||||
if (!candidate || candidate.blocked)
|
||||
throw new HomeworldCustodyError(candidate?.blocked ?? 'Choose a seated opposing faction for Tupile intelligence.');
|
||||
return { owner, target: candidate.player, faction: candidate.faction, category, contact: [...candidate.contact] };
|
||||
}
|
||||
Reference in new issue
Block a user