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:
1 parent
06f46c6f79
commit
9381ef140d
18 files changed
+1015
-25
No files matched your search
+35
-3
@@ -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} : {}),
|
||||
|
||||
Reference in new issue
Block a user