Connect optional Smuggler reserve shipment and saved interception

Prototype one free accompanying force into an empty territory with explicit opt-out, shared human/AI pricing, physical elite custody and Guild recipient accounting. Bind pending quotes to their unique saved Guild decision; preserve legacy unstamped full-price declarations. Smuggler battle collection and combined modules remain unfinished and gated. Repair a randomly dealt-out Karama fixture by relocating its actual physical card without changing inventory.

Validation: 70 focused tests; typecheck, lint, 4477 offline tests, production build and 43 HTTP tests passed. Independent rules/persistence review completed, including interruption and cancellation. Browser checked the price toggle, three arrivals for two spice, and refresh with unchanged private cards. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Smuggler.

Preserved all 427 opening rooms and original seat/recovery rows (461 rooms after QA/HTTP). SQLite integrity and foreign keys passed. Server remained healthy after the reported power outage; no restart or reset.

Verified source tree: bf6e02bf6873ca6d6b01c83481489f55b2c7db0184a7056364343440dae88803
This commit is contained in:
Quintine committed 2026-09-13 20:19:33 +10:00
1 parent 06f46c6f79
commit 9381ef140d
18 files changed
+1015 -25

No files matched your search

+6 -3
View File
@@ -65,6 +65,7 @@ import {
shrineTruthtranceBotAction,
} from './shrine';
import { reserveShipmentCost } from './shipment-price';
import { quoteSmugglerShipment } from './smuggler-shipment';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
@@ -3010,7 +3011,7 @@ function policyActions(g: GameView): Action[] {
if (marker?.location.territory === to.t) continue;
const desired = Math.min(
me.reserves,
level === 0 ? 1 : Math.max([1, 3, 4, 5][level], to.enemy + 2),
level === 0 ? (quoteSmugglerShipment(g, me.id, to.t, 2) ? 2 : 1) : Math.max([1, 3, 4, 5][level], to.enemy + 2),
);
// Quote the discounted candidate independently of ordinary affordability.
const richeseAmount = Math.min(me.reserves, 5, g.nexusRichese?.maxForces ?? 0);
@@ -3024,12 +3025,13 @@ function policyActions(g: GameView): Action[] {
});
if (candidate) actions.push(candidate);
}
for (const amount of new Set([desired, Math.min(desired, 3), 1])) {
for (const amount of new Set([desired, Math.min(desired, 3),
...(me.reserves >= 2 && quoteSmugglerShipment(g, me.id, to.t, 2) ? [2] : []), 1])) {
if (
reserveShipmentCost(
{ faction: me.faction, halfRate },
territory(to.t).type,
amount,
amount - (quoteSmugglerShipment(g, me.id, to.t, amount) ? 1 : 0),
) > shipmentBudget
)
continue;
@@ -3038,6 +3040,7 @@ function policyActions(g: GameView): Action[] {
territory: to.t,
sector: to.s,
amount,
...(quoteSmugglerShipment(g, me.id, to.t, amount) ? { smuggler: true } : {}),
...(level > 0
? { elite: Math.min(amount, me.elites?.reserves ?? 0) }
: {}),
+35 -3
View File
@@ -10,6 +10,7 @@ import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGro
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistLeader, planetologistRange, type PlanetologistMovement } from './planetologist-movement';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
@@ -891,6 +892,8 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
guildSecretEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
@@ -4914,6 +4917,12 @@ function leaderSkillAssignmentUnavailable(
function leaderSkillsIntegrity(g: Game) {
sukRescueIntegrity(g);
rihaniIntegrity(g);
if (g.leaderSkills || g.pendingShipment?.smuggler) {
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment');
requireRule(decisions.length === (g.pendingShipment ? 1 : 0),
'The saved Leader Skills shipment lost its unique Guild interception decision.');
for (const decision of decisions) validateGuildShipmentDecision(g, decision);
}
if (!g.leaderSkills) return;
requireRule(g.leaderSkills.assignments.every((assignment) =>
g.players.find((p) => p.id === assignment.owner)?.leaders.some((leader) => leader.id === assignment.leader && !leader.dead)),
@@ -13504,9 +13513,10 @@ function findShipmentCompletion(
for (const nexus of [undefined, ...(offer && !offer.blocked && amount <= offer.maxForces ? [offer.event] : []), ...(guildOffer && !guildOffer.blocked ? [guildOffer.event] : [])]) {
const guildSecretEvent = nexus && nexus === guildOffer?.event ? nexus : undefined;
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
const smuggler = nexus ? null : quoteSmugglerShipment(g, p.id, destination, amount);
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(destination).type,amount)).cost : nexus
? nexusRule(() => quoteNexusRicheseShipment(price,territory(destination).type,amount)).cost
: reserveShipmentCost(price,territory(destination).type,amount);
: reserveShipmentCost(price,territory(destination).type,amount - (smuggler ? 1 : 0));
try {
const allyPayment = contribution(g, p, cost);
const elite = eliteChoice(
@@ -13525,6 +13535,7 @@ function findShipmentCompletion(
: null;
const nativeSources = sources ? { homeworldSources: sources } : {};
const shipment: PendingShipment = {
...(smuggler ? { smuggler } : {}),
...(guildSecretEvent ? {guildSecretEvent} : nexus ? {nexusEvent:nexus} : {}),
...nativeSources,
turn: g.turn,
@@ -13545,6 +13556,7 @@ function findShipmentCompletion(
...actions,
{
type: 'ship',
...(smuggler ? { smuggler: true } : {}),
...(nexus ? {nexus} : {}),
territory: destination,
sector,
@@ -17067,6 +17079,12 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
} else commitHomeworldShipment(g, shipment);
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
if (shipment.smuggler) requireRule(
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
!g.expansions.length && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled && !g.strongholdCards && !g.techTokens,
'Smuggler shipping with expansion modules is still being integrated.',
);
if (shipment.guildSecretEvent) {
validateGuildSecretDeclaration(g,shipment.player,shipment.guildSecretEvent);
requireRule(!shipment.nexusEvent && !shipment.guildNexusEvent && !shipment.noField && !shipment.alliedNoField && !shipment.source,
@@ -17143,9 +17161,14 @@ function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
'Fremen reinforcements must arrive within two territories of the Great Flat.',
);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
if (shipment.smuggler) requireRule(
!shipment.guildSecretEvent && !shipment.nexusEvent &&
JSON.stringify(shipment.smuggler) === JSON.stringify(quoteSmugglerShipment(g, p.id, shipment.territory, n)),
'The saved Smuggler shipment lost its living trainer, empty destination or physical amount.',
);
const cost = shipment.guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(shipment.territory).type,n)).cost : shipment.nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(shipment.territory).type,n)).cost
: reserveShipmentCost(price,territory(shipment.territory).type,n);
: reserveShipmentCost(price,territory(shipment.territory).type,n - (shipment.smuggler ? 1 : 0));
requireRule(
Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
'The declared shipment price is no longer current.',
@@ -17341,6 +17364,9 @@ function commitShipment(g: Game, shipment: PendingShipment) {
? { faction: 'richese', name: 'No-Field shipment' }
: shipment.guildSecretEvent ? {faction:'guild',name:'Secret Ally shipment'} : shipment.nexusEvent ? {faction:'richese',name:'Secret Ally shipment'} : undefined,
);
if (shipment.smuggler) log(g,
`${p.name}'s Smuggler included one free accompanying force: ${n} physical forces left reserves, priced as ${n - 1} for ${cost} spice. The destination was empty before arrival.`,
{ faction: p.faction, name: 'Smuggler shipment' });
const bg = byFaction(g, 'beneGesserit');
const followup =
p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0
@@ -21648,9 +21674,14 @@ function applyActionInner(
? 0
: eliteChoice(n, p.reserves, p.elites?.reserves ?? 0, action.elite);
const price = {faction:p.faction,halfRate:shipmentHalfRate(g,p)};
requireRule(action.smuggler === undefined || typeof action.smuggler === 'boolean', 'Choose whether to use Smuggler.');
const smuggler = action.smuggler === true && !noField && !guildSecretEvent && !nexusEvent
? quoteSmugglerShipment(g, id, to, n) : null;
requireRule(action.smuggler !== true || smuggler,
'Smuggler needs a living face-up native trainer and at least two off-planet forces shipping into an empty territory.');
const cost = guildSecretEvent ? nexusRule(() => quoteNexusGuildSecretShipment(territory(to).type,n)).cost : nexusEvent
? nexusRule(() => quoteNexusRicheseShipment(price,territory(to).type,n)).cost
: reserveShipmentCost(price,territory(to).type,n);
: reserveShipmentCost(price,territory(to).type,n - (smuggler ? 1 : 0));
if (p.faction === 'fremen' && !guildSecretEvent) {
requireRule(
territory('the_great_flat').sectors.some((fs) =>
@@ -21668,6 +21699,7 @@ function applyActionInner(
);
const allyPayment = contribution(g, p, cost, action.allyPayment);
const shipment: PendingShipment = {
...(smuggler ? { smuggler } : {}),
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}),
+8 -7
View File
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice and Rihani exchanges in development tables. Other effects and combined-module acceptance remain unfinished.',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice, Rihani exchanges and optional Smuggler shipping in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
@@ -3575,16 +3575,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 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.',
'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.',
'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, plus all Spice Banker, Diplomat and Smuggler effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
'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. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'Normal Mentat questioning, Bureaucrat payments and Sandmaster movement collection, Smuggler battle collection, plus all Spice Banker and Diplomat effects remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['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. Their other bands, Spice Banker, Diplomat, Smuggler and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.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. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.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 and shared battle scoring. This is a legal prototype path; 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'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery and battle-effect regressions cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx'] },
{ 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 is connected. Remaining bands, Spice Banker, Diplomat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.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 an explicit shipping opt-out and total-versus-priced force counts. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.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 and Smuggler reserve shipments. This is a legal prototype path; 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/SMUGGLER_SHIPMENT.md'] },
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery checks cover this boundary. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts'] },
],
},
...[
+39
View File
@@ -0,0 +1,39 @@
import type { Game, GameView } from './engine';
import { presenceAt } from './force-presence';
/** The amount is the entire physical shipment, including its one free companion. */
export type SmugglerShipment = { leader: string; amount: number };
/** Public leader custody and whole-territory presence suffice; no private hand is read. */
export function quoteSmugglerShipment(
game: Game | GameView,
player: string,
territory: string,
amount: number,
): SmugglerShipment | null {
if (!Number.isSafeInteger(amount) || amount < 2) return null;
// Classic Fremen reserves are on-planet; their normal reinforcement is not off-planet shipping.
if (game.players.find((p) => p.id === player)?.faction === 'fremen')
return null;
const assignment = game.leaderSkills?.assignments.find(
(a) => a.owner === player && a.skill === 'smuggler',
);
if (
!assignment ||
('controller' in assignment && assignment.controller !== player) ||
('faceUp' in assignment && !assignment.faceUp)
)
return null;
const leader = game.players
.find((p) => p.id === player)
?.leaders.find((l) => l.id === assignment.leader);
if (
!leader ||
leader.dead ||
leader.capturedBy ||
leader.gholaBy ||
game.players.some((p) => presenceAt(p, territory) > 0)
)
return null;
return { leader: leader.id, amount };
}