Connect owned multi-pile battle skill choices

This commit is contained in:
Quintine committed 2026-10-09 03:14:14 +11:00
1 parent 8e204218be
commit a1517ec949
20 files changed
+705 -71

No files matched your search

+2
View File
@@ -25,6 +25,8 @@ const nullableCount = (value: unknown) => value === null || count(value);
const decisions = {
ecazBattleLead: true,
harassWithdraw: true,
smugglerCollection: true,
sandmasterVictorySpice: true,
diplomatDefense: true,
diplomatRetreat: true,
leaderSkillVisibility: true,
+6 -1
View File
@@ -4,7 +4,7 @@ import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
import { smugglerBattlePlanBlock } from './smuggler-battle';
import { defaultSmugglerBattleAllocation, smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw';
@@ -1611,6 +1611,11 @@ function policyActions(g: GameView): Action[] {
// A decision without its private view offer must not become an ordinary action.
if (d.kind === 'nexusIxianReplacement') return [];
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion' || d.kind === 'bureaucratPayment') return [];
if (d.kind === 'smugglerCollection')
return [{ type: 'decision', event: d.event,
allocations: defaultSmugglerBattleAllocation(d.piles, d.amount) }];
if (d.kind === 'sandmasterVictorySpice')
return d.piles.length ? [{ type: 'decision', event: d.event, key: d.piles[0].key }] : [];
if (d.kind === 'harassWithdraw') {
const offer = g.battle?.harassAllocation;
if (!offer || offer.event !== d.event) return [];
+78 -14
View File
@@ -15,12 +15,12 @@ import {
} from './leader-skill-combat';
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces';
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
import { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, type SmugglerBattleReceipt } from './smuggler-battle';
import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported, smugglerBattlePlanBlock, smugglerBattlePile, smugglerBattleSignature, smugglerBattleAllocationRequired, chooseSmugglerBattleAllocation, type SmugglerBattleReceipt, type SmugglerBattleCollectionOffer } from './smuggler-battle';
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterCollectible, sandmasterLeader, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { sandmasterWormCollection } from './sandmaster-worm';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
@@ -693,6 +693,7 @@ export type Battle = {
/** New battles bind automatic collection to the original public plan reveal. */
smugglerCollectionVersion?: 1;
smugglerCollection?: SmugglerBattleReceipt | null;
sandmasterPile?: string;
/** New battles allow physical withdrawal choice after reveal, never in the sealed plan. */
harassAllocationVersion?: 1;
harassAllocationEvent?: string;
@@ -790,6 +791,8 @@ export type Auction = {
export type Decision =
| { kind: 'ecazBattleLead'; player: string; event: string; choices: string[] }
| { kind: 'harassWithdraw'; player: string; event: string }
| ({ kind: 'smugglerCollection' } & SmugglerBattleCollectionOffer)
| ({ kind: 'sandmasterVictorySpice' } & SandmasterVictorySpiceOffer)
| { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string }
| { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] }
| { kind: 'leaderSkillVisibility'; player: string; event: string; resumePowers?: boolean }
@@ -21232,10 +21235,24 @@ function smugglerBattleIntegrity(g: Game) {
const b = g.battle;
if (b?.smugglerCollectionVersion !== undefined || b?.smugglerCollection !== undefined) {
requireRule(b && b.smugglerCollectionVersion === 1, 'The saved Smuggler collection lost its battle version.');
if (b.revealed) requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(pendingSmugglerBattle(g)),
'The saved Smuggler collection changed its original plans, leader or reveal-time spice.');
if (b.revealed) {
let expected = pendingSmugglerBattle(g);
if (expected && b.smugglerCollection?.allocations)
expected = nexusRule(() => chooseSmugglerBattleAllocation(expected!, b.smugglerCollection!.allocations!));
requireRule(JSON.stringify(b.smugglerCollection) === JSON.stringify(expected),
'The saved Smuggler collection changed its original plans, leader or reveal-time spice.');
}
else requireRule(b.smugglerCollection === undefined, 'Smuggler collection cannot precede public Battle Plans.');
}
if (g.decision?.kind === 'smugglerCollection' || g.decision?.kind === 'sandmasterVictorySpice') {
requireRule(b?.revealed && g.phase === 6 && b.event === g.decision.event &&
traitorVoters(g, b).every(id => b.traitorCalls[id] !== undefined),
'The pile choice needs the completed revealed battle outcome.');
const quote = currentBattleResolutionQuote(g);
requireRule(JSON.stringify(g.decision) === JSON.stringify(
g.decision.kind === 'smugglerCollection' ? quote.smugglerAllocation : quote.sandmasterSelection),
'The saved pile choice changed its owner, amount or sectors.');
}
const context = g.lastBattleContext, receipt = context?.smugglerCollection;
if (receipt) requireRule(receipt.event === context!.event && receipt.turn === context!.turn &&
receipt.territory === context!.territory && context!.combatants.includes(receipt.player) &&
@@ -21319,11 +21336,29 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
ordinaryLeaderSkillModeSupported(g) &&
(nativeExpansionLeaderSkillsProfile(g) || g.players.every((p) => faction(p.faction).expansion === 'base')),
'These Leader Skill victory effects with expansion factions or other optional modules are still being integrated.');
const smuggler = b.smugglerCollection ? nexusRule(() => settleSmugglerBattle(b.smugglerCollection!,
!(b.smugglerCollection!.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender), g.spice)) : null;
if (smuggler) requireRule(Number.isSafeInteger(getPlayer(g, smuggler.receipt.player).spice + smuggler.amount),
'Smuggler collection would overflow the spice balance.');
if (quote.sandmaster) nexusRule(() => sandmasterVictorySpice(b.territory, smuggler?.spice ?? g.spice));
const collection = b.smugglerCollection;
const survives = !!collection &&
!(collection.player === b.attacker ? quote.leaderDeaths.attacker : quote.leaderDeaths.defender);
const smugglerAllocation: Extract<Decision, {kind: 'smugglerCollection'}> | null =
collection && smugglerBattleAllocationRequired(collection, survives)
? { kind: 'smugglerCollection', event: b.event!, player: collection.player,
territory: b.territory, amount: collection.amount, piles: collection.piles! }
: null;
const smuggler = collection && !smugglerAllocation
? nexusRule(() => settleSmugglerBattle(collection, survives, g.spice)) : null;
if (collection && survives)
requireRule(Number.isSafeInteger(getPlayer(g, collection.player).spice + collection.amount),
'Smuggler collection would overflow the spice balance.');
const afterCollection = smuggler?.spice ?? g.spice;
const sandmasterPiles = quote.sandmaster && !smugglerAllocation
? nexusRule(() => sandmasterVictoryPiles(b.territory, afterCollection)) : [];
const sandmasterSelection: Extract<Decision, {kind: 'sandmasterVictorySpice'}> | null =
sandmasterPiles.length > 1 && !b.sandmasterPile
? { kind: 'sandmasterVictorySpice', event: b.event!, player: quote.winner!,
territory: b.territory, piles: sandmasterPiles }
: null;
if (quote.sandmaster && !smugglerAllocation && !sandmasterSelection)
nexusRule(() => sandmasterVictorySpice(b.territory, afterCollection, b.sandmasterPile));
if (quote.rihani) {
const winner = getPlayer(g, quote.winner!);
nexusRule(() => beginRihani(nexusTraitorSnapshot(g), nexusTraitorUniverse(g),
@@ -21346,7 +21381,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite);
if (quote.homeworldExplosion)
for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
return { ...quote, smuggler };
return { ...quote, smuggler, smugglerAllocation, sandmasterSelection };
} catch (error) {
if (
error instanceof BattleResolutionQuoteError ||
@@ -21428,13 +21463,18 @@ function diplomatRetreatIntegrity(g: Game) {
}
function advanceBattleOutcome(g: Game) {
const b = g.battle!;
const quote = currentBattleResolutionQuote(g);
if (quote.smugglerAllocation || quote.sandmasterSelection) {
g.decision = quote.smugglerAllocation ?? quote.sandmasterSelection;
return;
}
if (b.diplomatRetreatVersion !== 1 || ![b.attacker, b.defender].some(id =>
battleLeaderSkills(g, getPlayer(g, id)).some(skill => skill.skill === 'diplomat' &&
b.plans[id].leader === skill.leader && (!skill.faceUp || skill.captured)))) {
resolveBattle(g);
return;
}
const offer = diplomatRetreatOffer(g, currentBattleResolutionQuote(g));
const offer = diplomatRetreatOffer(g, quote);
if (!offer) { resolveBattle(g); return; }
requireRule(b.event && !b.diplomatRetreat, 'The Diplomat retreat needs one current battle.');
const event = crypto.randomUUID();
@@ -21449,6 +21489,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
// Quote before any withdrawal: fixedLosses retains the original ceil(E/2)
// physical commitment, never one recalculated from Ecaz's diminished board.
const quote = currentBattleResolutionQuote(g);
requireRule(!quote.smugglerAllocation && !quote.sandmasterSelection,
'Finish the owned battle spice-pile choice before resolving losses.');
const a = getPlayer(g, b.attacker),
d = getPlayer(g, b.defender),
ap = b.plans[a.id],
@@ -21826,16 +21868,20 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (quote.smuggler) {
const { receipt, amount } = quote.smuggler;
const collector = getPlayer(g, receipt.player);
if (amount && receipt.key) g.spice[receipt.key] = receipt.before - amount;
if (amount) {
if (receipt.piles)
for (const key of Object.keys(receipt.piles)) g.spice[key] = quote.smuggler!.spice[key];
else if (receipt.key) g.spice[receipt.key] = receipt.before - amount;
}
collector.spice += amount;
g.lastBattleContext.smugglerCollection = receipt;
log(g, receipt.stage === 'void'
? `${collector.name}'s Smuggler did not survive. Its pending collection is void; no spice was taken from ${combatLocationName(g, b.territory)}.`
: `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time pile held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`,
: `${collector.name}'s surviving Smuggler collected ${amount} spice from ${combatLocationName(g, b.territory)}. The reveal-time piles held ${receipt.before}; the leader's unmodified strength was ${receipt.strength}. ${receipt.before - amount} spice remains before any after-victory effects.`,
{ faction: collector.faction, name: 'Smuggler collection' });
}
if (winner && quote.sandmaster) {
const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice));
const placement = nexusRule(() => sandmasterVictorySpice(b.territory, g.spice, b.sandmasterPile));
if (placement) {
g.spice[placement.key] = placement.after;
g.lastBattleContext.sandmaster = { leader: quote.sandmaster, ...placement };
@@ -29234,6 +29280,24 @@ function applyActionInner(
if (destination === null)
log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' });
resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number });
} else if (decision.kind === 'smugglerCollection') {
const receipt = g.battle?.smugglerCollection;
requireRule(receipt?.event === decision.event && receipt.player === id &&
action.event === decision.event && action.allocations !== undefined &&
Object.keys(action).every(key => ['type', 'event', 'allocations'].includes(key)),
'Choose the exact Smuggler amount from its offered reveal-time piles.');
g.battle!.smugglerCollection = nexusRule(() => chooseSmugglerBattleAllocation(
receipt!, action.allocations as Record<string, number>));
g.decision = null;
advanceBattleOutcome(g);
} else if (decision.kind === 'sandmasterVictorySpice') {
requireRule(g.battle?.event === decision.event && action.event === decision.event &&
typeof action.key === 'string' && decision.piles.some(pile => pile.key === action.key) &&
Object.keys(action).every(key => ['type', 'event', 'key'].includes(key)),
'Choose one existing pile for this surviving Sandmaster victory.');
g.battle!.sandmasterPile = action.key as string;
g.decision = null;
advanceBattleOutcome(g);
} else if (decision.kind === 'harassWithdraw') {
const receipt = g.battle?.harassAllocation, offer = harassAllocationOffer(g);
requireRule(Object.keys(action).every(key => ['type', 'event', 'returns'].includes(key)) &&
+40 -9
View File
@@ -1,4 +1,5 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { gameTerritories, location, splitLocation, validLocation } from './board';
import type { MobileBoard } from './board';
import { fighterCount } from './advisors';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import type { ForcePresence } from './force-presence';
@@ -14,13 +15,43 @@ export function leaderSkillStrongholdCount(
fighterCount(player, t.id) > 0).length;
}
export type SandmasterVictorySpiceOffer = {
event: string;
player: string;
territory: string;
piles: Array<{ key: string; before: number; after: number }>;
};
/** The card adds board spice to an existing pile; it grants no direct faction income. */
export function sandmasterVictorySpice(territory: string, spice: Readonly<Record<string, number>>) {
const piles = Object.entries(spice).filter(([key, amount]) => amount > 0 && splitLocation(key).territory === territory);
if (piles.length > 1) throw new Error('Sandmaster placement among multiple spice piles awaits its sector ruling.');
if (!piles.length) return null;
const [key, before] = piles[0];
if (!Number.isSafeInteger(before) || before < 1 || !Number.isSafeInteger(before + 3))
throw new Error('Sandmaster requires a valid existing spice pile.');
return { key, before, after: before + 3 };
export function sandmasterVictoryPiles(
territory: string,
spice: Readonly<Record<string, number>>,
): Array<{ key: string; before: number; after: number }> {
const piles: Array<{ key: string; before: number; after: number }> = [];
for (const [key, before] of Object.entries(spice)) {
const at = splitLocation(key);
if (at.territory !== territory) continue;
if (!validLocation(at.territory, at.sector) || key !== location(at.territory, at.sector) ||
!Number.isSafeInteger(before) || before < 0 ||
(before > 0 && !Number.isSafeInteger(before + 3)))
throw new Error('Sandmaster requires a valid existing spice pile.');
if (before > 0) piles.push({ key, before, after: before + 3 });
}
return piles;
}
/** Owner selection among existing piles is provisional, not a publisher ruling. */
export function sandmasterVictorySpice(
territory: string,
spice: Readonly<Record<string, number>>,
key?: string,
) {
const piles = sandmasterVictoryPiles(territory, spice);
if (key !== undefined) {
const selected = piles.find((pile) => pile.key === key);
if (!selected) throw new Error('Sandmaster must select an offered existing spice pile.');
return selected;
}
if (piles.length > 1) throw new Error('Sandmaster requires selection of one existing spice pile.');
return piles[0] ?? null;
}
+4 -4
View File
@@ -4731,14 +4731,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. When a native Ixian Hidden Mobile Stronghold relocates, its interior passengers count as entering the territories it points into, so the Sandmaster collects there before the faction’s own traversed-sector collection. Multiple piles and other special relocation remain unfinished.',
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
'A surviving skilled Sandmaster winner adds three board spice to an existing pile in the battle territory, even with no surviving forces. No pile adds nothing; a single pile resolves automatically. With several remaining positive piles, the winner selects one sector after any Smuggler allocation. Owner selection is a visibly provisional placement policy, not a publisher ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.',
'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.',
'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers in supported classic and native skill profiles, including bounded Advanced Moritani. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
'Smuggler records all existing positive battle-territory piles when plans reveal, capped in total by the selected leader’s unmodified strength. A surviving controller collects even when losing or no forces remain. A partial collection across several piles asks that owner for exact sector amounts; all/none and single-pile collection stay automatic. This allocation policy is provisional. Pending spice cannot fund the plan. Collection precedes Sandmaster addition; a drained pile receives none. Modified strength and other combined-module limits remain guarded. Older saved battles retain their recorded behavior.',
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
@@ -4746,8 +4746,8 @@ export const RULE_TOPICS: RuleTopic[] = [
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
checklist: [
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route, native Fremen worm-ride and physical Fremen Ambassador destination collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat canonical printed-defense copying are connected; skilled Diplomat retreat transfers undialed normal/elite forces before losing casualties. Normal Banker income connects only in its separate fresh profiles; remaining bands and combined modules stay open. Bureaucrat redirects supported third-party auction, shipment and bribe payments with saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and optional worm/physical Ambassador destination checkboxes. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route, worm and physical Ambassador destination collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
{ area:'Verification',status:'Partial',detail:'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',evidence:['tests/homeworld-classic-nexus-skills-runtime.test.ts','tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
],
+2
View File
@@ -19,6 +19,8 @@ const DECISIONS = {
diplomatDefense: true,
diplomatRetreat: true,
harassWithdraw: true,
smugglerCollection: true,
sandmasterVictorySpice: true,
choamAudit: true,
nexusChoamInspection: true,
choamAuditPayment: true,
+82 -8
View File
@@ -7,8 +7,25 @@ import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSk
export type SmugglerBattleReceipt = {
event: string; turn: number; territory: string; player: string; leader: string;
frame: string; strength: number; key: string | null; before: number; amount: number;
piles?: Record<string, number>; allocations?: Record<string, number>;
stage: 'pending' | 'collected' | 'void'; signature: string;
};
export type SmugglerBattleCollectionOffer = {
event: string; player: string; territory: string; amount: number; piles: Record<string, number>;
};
/** Minimal legal participation; strategy must not invent hidden pile information. */
export function defaultSmugglerBattleAllocation(piles: Readonly<Record<string, number>>, amount: number) {
const allocations: Record<string, number> = {};
let remaining = amount;
for (const key of Object.keys(piles).sort()) {
const collected = Math.min(remaining, piles[key]);
if (collected > 0) allocations[key] = collected;
remaining -= collected;
if (!remaining) break;
}
return allocations;
}
export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) {
return ordinaryLeaderSkillModeSupported(g) &&
(nativeExpansionLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
@@ -35,16 +52,20 @@ export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boo
export function smugglerBattleSignature(receipt: SmugglerBattleReceipt): string {
return JSON.stringify({ ...receipt, signature: undefined });
}
export function smugglerBattlePile(territory: string, spice: Readonly<Record<string, number>>) {
export function smugglerBattlePile(territory: string, spice: Readonly<Record<string, number>>): {
key: string | null; before: number; piles?: Record<string, number>;
} {
const piles = Object.entries(spice).filter(([key]) => splitLocation(key).territory === territory);
for (const [key, amount] of piles) {
const at = splitLocation(key);
if (!validGameLocation({}, at.territory, at.sector) || key !== `${at.territory}:${at.sector}` ||
!Number.isSafeInteger(amount) || amount < 0) throw new Error('Smuggler needs a valid spice pile.');
}
const positive = piles.filter(([, amount]) => amount > 0);
if (positive.length > 1) throw new Error('Smuggler collection among multiple spice piles awaits its allocation ruling.');
return positive.length ? { key: positive[0][0], before: positive[0][1] } : { key: null, before: 0 };
const positive = piles.filter(([, amount]) => amount > 0).sort(([a], [b]) => a.localeCompare(b));
const before = positive.reduce((total, [, amount]) => total + amount, 0);
if (!Number.isSafeInteger(before)) throw new Error('Smuggler needs a safe aggregate spice amount.');
return positive.length > 1 ? { key: null, before, piles: Object.fromEntries(positive) } :
positive.length ? { key: positive[0][0], before } : { key: null, before: 0 };
}
export function createSmugglerBattle(input: {
event: string; turn: number; territory: string; player: string; frame: string;
@@ -63,14 +84,67 @@ export function createSmugglerBattle(input: {
receipt.signature = smugglerBattleSignature(receipt);
return receipt;
}
function recordedPiles(receipt: SmugglerBattleReceipt): Record<string, number> {
return receipt.piles ?? (receipt.key ? { [receipt.key]: receipt.before } : {});
}
function validateSmugglerBattleReceipt(receipt: SmugglerBattleReceipt) {
if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) ||
!Number.isSafeInteger(receipt.strength) || receipt.strength < 0 ||
!Number.isSafeInteger(receipt.before) || receipt.before < 0 ||
receipt.amount !== Math.min(receipt.strength, receipt.before))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
const snapshot = smugglerBattlePile(receipt.territory, recordedPiles(receipt));
if (snapshot.key !== receipt.key || snapshot.before !== receipt.before ||
(!!snapshot.piles !== !!receipt.piles) ||
(receipt.piles && Object.keys(receipt.piles).length !== Object.keys(snapshot.piles!).length))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
if (receipt.allocations !== undefined) normalizeSmugglerBattleAllocation(receipt, receipt.allocations);
}
function normalizeSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
allocations: Record<string, number>): Record<string, number> {
if (!allocations || typeof allocations !== 'object' || Array.isArray(allocations))
throw new Error('Choose a Smuggler allocation among the revealed spice piles.');
const piles = recordedPiles(receipt);
let total = 0;
for (const [key, amount] of Object.entries(allocations)) {
if (!Object.prototype.hasOwnProperty.call(piles, key) || piles[key] <= 0 ||
!Number.isSafeInteger(amount) || amount < 0 || amount > piles[key])
throw new Error('Smuggler allocation must use whole spice within each revealed positive pile.');
total += amount;
}
if (total !== receipt.amount) throw new Error('Smuggler allocation must collect the exact pending amount.');
return Object.fromEntries(Object.entries(allocations).filter(([, amount]) => amount > 0)
.sort(([a], [b]) => a.localeCompare(b)));
}
/** Only a surviving leader's partial collection from multiple piles needs an owner choice. */
export function smugglerBattleAllocationRequired(receipt: SmugglerBattleReceipt, survives: boolean): boolean {
return survives && receipt.stage === 'pending' && receipt.allocations === undefined &&
Object.keys(recordedPiles(receipt)).length > 1 && receipt.amount > 0 && receipt.amount < receipt.before;
}
export function chooseSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
allocations: Record<string, number>): SmugglerBattleReceipt {
validateSmugglerBattleReceipt(receipt);
const chosen: SmugglerBattleReceipt = { ...receipt,
...(receipt.piles ? { piles: { ...receipt.piles } } : {}),
allocations: normalizeSmugglerBattleAllocation(receipt, allocations), signature: '' };
chosen.signature = smugglerBattleSignature(chosen);
return chosen;
}
/** Survival controls both winning and losing leaders; this is not bank/player income. */
export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, survives: boolean, spice: Readonly<Record<string, number>>) {
if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) ||
receipt.amount !== Math.min(receipt.strength, receipt.before) ||
JSON.stringify(smugglerBattlePile(receipt.territory, spice)) !== JSON.stringify({ key: receipt.key, before: receipt.before }))
validateSmugglerBattleReceipt(receipt);
const snapshot = smugglerBattlePile(receipt.territory, spice);
const expected = smugglerBattlePile(receipt.territory, recordedPiles(receipt));
if (JSON.stringify(snapshot) !== JSON.stringify(expected))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
if (smugglerBattleAllocationRequired(receipt, survives))
throw new Error('Choose the Smuggler collection sectors before settling the surviving leader.');
const amount = survives ? receipt.amount : 0;
const allocations = !survives || !amount ? {} : receipt.allocations ??
(receipt.key ? { [receipt.key]: amount } : recordedPiles(receipt));
const remaining = { ...spice };
for (const [key, collected] of Object.entries(allocations)) remaining[key] -= collected;
const settled: SmugglerBattleReceipt = { ...receipt, stage: survives ? 'collected' : 'void', signature: '' };
settled.signature = smugglerBattleSignature(settled);
return { receipt: settled, amount, spice: receipt.key ? { ...spice, [receipt.key]: receipt.before - amount } : { ...spice } };
return { receipt: settled, amount, spice: remaining };
}