Integrate Smuggler companion with owned Richese No-Field

Connect the GF9 FAQ combination: one free physical reserve force accompanies an owned concealed No-Field at its unchanged one-marker price. Preserve zero/three/five token values, actual supply at later reveal, optional human choice and all four legal AI paths. Genuine CHOAM & Richese Leader Skills development setup adds the two sourced battle cards without duplicating their Ix identities. Public mode and publication gates stay closed; allied and remaining combined routes are unfinished.

Bind the private declaration across initial No-Field cancellation and later Advanced Guild interception, including saved interruptions. Validate before projection/actions/normalization, hide the internal proof, reject altered declarations immutably, and withdraw the companion once only on successful commitment. Independent rules/privacy/persistence review found no blocker. Preserve other audit setup gates despite expanded deck assembly. Repair the existing randomized recovery test with a genuine seeded Rihani offer and explicit human choice, replacing an inappropriate AI-policy call on a human seat. Initial Karama and later Guild stop retain their distinct existing opportunity outcomes.

Validation: 72 focused regressions plus 10 deck tests; typecheck, lint, 4520 offline tests, production build and 43 HTTP tests passed. SQLite restart, concurrent allowance/cancellation and exact physical custody passed. Browser genuine assignment, private token selection, opt-in, one-spice shipment, refresh, five-force reveal and final refresh preserved six physical Arrakeen forces, 14 reserves, zero Tanks, four spice, Crysknife and Soo-Soo Sook. Genuine four-profile Basic/Advanced games completed in 77/116 actions with 3/4 JSON restores and no rejected candidates; Advanced exercised one companion shipment and later reveal.

After the outage the existing server was healthy. Preserved all 529 opening rooms and original seat/recovery records (563 after QA/HTTP). SQLite integrity and foreign keys passed. No restart, database reset or automation.

Verified source tree: c2aee4138bcb46a05814a340d62eadace3470247ca620844eb0375e78ea4d6c4
This commit is contained in:
Quintine committed 2026-09-13 21:37:50 +10:00
1 parent bf8036dfc1
commit 788453e07e
20 files changed
+1213 -51

No files matched your search

+14 -2
View File
@@ -66,6 +66,7 @@ import {
} from './shrine';
import { reserveShipmentCost } from './shipment-price';
import { quoteSmugglerShipment } from './smuggler-shipment';
import { quoteSmugglerNoField } from './smuggler-no-field';
import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
@@ -2947,11 +2948,21 @@ function policyActions(g: GameView): Action[] {
(token) => token.id === marker?.tokenId,
);
const materialized = Math.min(deployedToken?.value ?? 0, me.reserves);
const physicalAlongsideMarker = marker
? countAt(me, marker.location.territory) > 0
: false;
const reveal =
noField?.canReveal && marker && materialized > 0
noField?.canReveal &&
marker &&
(materialized > 0 || physicalAlongsideMarker)
? { type: 'revealNoField', token: marker.tokenId, event: noField.event }
: null;
if (reveal && (!me.shipped || (me.moved ?? 0) >= (me.movesAllowed ?? 1)))
if (
reveal &&
(physicalAlongsideMarker ||
!me.shipped ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))
)
return [reveal];
const markerTargets = targets.filter(
(to) =>
@@ -2986,6 +2997,7 @@ function policyActions(g: GameView): Action[] {
territory: to.t,
sector: to.s,
allyPayment: Math.max(0, cost - (me.spice ?? 0)),
...(quoteSmugglerNoField(g, me.id, to.t) ? { smuggler: true } : {}),
});
}
}
+8 -3
View File
@@ -55,11 +55,16 @@ export function ixStandardCards(): Card[] {
export function ixDeck(): Card[] {
return [...ixBattleCards(), ...ixSpecialCards(), ...ixStandardCards()];
}
/** Complete currently implemented deck sets. Other expansion decks remain gated. */
/** CHOAM & Richese p. 4 replaces these Ix cards, or adds them when Ix is absent.
* Preserve one physical identity per card across either expansion combination. */
export function choamDeck(): Card[] {
return ixBattleCards().filter(c => c.kind === 'poisonTooth' || c.kind === 'artillery');
}
/** Deck assembly does not open the separate faction/module release gates. */
export function treacheryDeck(expansions: readonly string[] = []): Card[] {
if (expansions.some((id) => id !== 'ix'))
if (expansions.some((id) => id !== 'ix' && id !== 'choam'))
throw new Error('This expansion treachery deck is not implemented yet.');
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : [])];
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : expansions.includes('choam') ? choamDeck() : [])];
}
export type Leader = {
/** Explicit projected custody for a shared disc; null means set aside. */
+113 -14
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 { quoteSmugglerNoField, smugglerNoFieldModeSupported, type SmugglerNoFieldCompanion } from './smuggler-no-field';
import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipment';
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
@@ -724,6 +725,8 @@ export type Decision =
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
| {
kind: 'guildShipment';
/** Private original No-Field declaration; never projected to any seat. */
noFieldSkillProof?: string;
player: string;
shipper: string;
territory: string;
@@ -794,6 +797,7 @@ export type Decision =
}
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
export type ResponseWindow = {
noFieldSkillProof?: string;
/** Original optional collection binds a pending ground movement, including saved responses. */
sandmasterProof?: string;
/** Original eligible contributor amounts; private routing evidence, not a new payment. */
@@ -900,6 +904,8 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
smugglerCompanion?: SmugglerNoFieldCompanion;
noFieldSkillProof?: string;
guildSecretEvent?: string;
guildNexusEvent?: string;
nexusEvent?: string;
@@ -4921,6 +4927,7 @@ function leaderSkillAssignmentUnavailable(
: null;
}
function leaderSkillsIntegrity(g: Game) {
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
sandmasterIntegrity(g);
sukRescueIntegrity(g);
rihaniIntegrity(g);
@@ -4933,7 +4940,7 @@ function leaderSkillsIntegrity(g: Game) {
nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), leader?.id, plan.bankerSpice,
p.spice, battleSupportCost(g, p, plan.support) - (plan.allyPayment ?? 0)));
}
if (g.leaderSkills || g.pendingShipment?.smuggler) {
if (!noFieldShipment && (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.');
@@ -5356,9 +5363,9 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
return initializeSetupGameForAudit(g, false, false, false, false, true);
return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam');
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false): Game {
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false): Game {
nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -5380,7 +5387,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
'The audit initializer requires two through six distinct ready players and an existing host.',
);
requireRule(
(homeworlds || nexus || ix || g.expansions.length === 0) &&
(homeworlds || nexus || ix || choam || g.expansions.length === 0) &&
(choam || !g.expansions.includes('choam')) &&
(leaderSkills || !g.leaderSkills) &&
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
@@ -5389,10 +5397,12 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix')),
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam')),
),
),
ix
choam
? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.'
: ix
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
: homeworlds
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
@@ -17149,7 +17159,81 @@ function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentI
log(g, `${p.name} declared ${quote.amount} physical forces for ${combatLocationName(g, intent.destination)}. Payment and departure await the Guild interception decision.`);
} else commitHomeworldShipment(g, shipment);
}
function noFieldSkillProof(shipment: PendingShipment): string {
return JSON.stringify({ ...shipment, noFieldSkillProof: undefined });
}
function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment) {
requireRule(g.leaderSkills && smugglerNoFieldModeSupported(g) && shipment.noField &&
!shipment.alliedNoField && !shipment.smuggler && !shipment.source &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
shipment.homeworldSources === undefined && shipment.ambassadorEvent === undefined,
'Smuggler No-Field shipping requires an own Richese marker without other shipment routes or modules.');
requireRule(typeof shipment.noFieldSkillProof === 'string' &&
shipment.noFieldSkillProof === noFieldSkillProof(shipment),
'The saved No-Field shipment changed its original marker or optional Smuggler companion.');
const p = getPlayer(g, shipment.player);
requireRule(g.status === 'playing' && g.phase === 5 && g.active === p.id &&
shipmentAvailable(g, p) && shipment.turn === g.turn && p.faction === 'richese' &&
p.noField && p.noFieldEvent === shipment.noField.event && p.noFieldBlockedTurn !== g.turn &&
!p.noField.deployed && shipment.amount === 1 && shipment.elite === 0 && shipment.advisors === false,
'The saved No-Field shipment lost its current unused opportunity or concealed marker.');
integer(shipment.sector, 0, 18, 'No-Field shipment sector');
requireRule(shipment.territory !== MOBILE_STRONGHOLD, 'Richese cannot ship into the mobile stronghold.');
allowedEntry(g, p, shipment.territory, shipment.sector, false, false);
noFieldRule(() => deployRicheseNoField(p.noField!, {
tokenId: shipment.noField!.tokenId,
controller: p.id,
location: { territory: shipment.territory, sector: shipment.sector },
}));
if (shipment.smugglerCompanion !== undefined) requireRule(
JSON.stringify(shipment.smugglerCompanion) === JSON.stringify(quoteSmugglerNoField(g, p.id, shipment.territory)) &&
shipment.smugglerCompanion !== null,
'The saved Smuggler No-Field companion lost its living trainer, reserve force or empty destination.',
);
const cost = reserveShipmentCost({ faction: p.faction, halfRate: shipmentHalfRate(g, p) },
territory(shipment.territory).type, 1);
requireRule(Number.isSafeInteger(shipment.cost) && shipment.cost === cost,
'A No-Field with an optional Smuggler force keeps the price of one marker.');
integer(p.spice, 0, Number.MAX_SAFE_INTEGER, 'Current No-Field shipment spice');
integer(shipment.allyPayment, 0, cost, 'No-Field shipment ally payment');
const credit = aidFor(g, p);
if (credit) integer(credit.amount, 0, Number.MAX_SAFE_INTEGER, 'Current ally pledge');
if (shipment.allyPayment > 0) requireRule(p.ally && getPlayer(g, p.ally).ally === p.id,
'The declared No-Field contributor is no longer your mutual ally.');
contribution(g, p, cost, shipment.allyPayment);
}
/** Bind both private stages, also while a card effect has saved their controls. */
function leaderSkillNoFieldIntegrity(g: Game): boolean {
const continuation = g.pendingTreacheryDiscard?.continuation;
const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume,
continuation && 'resume' in continuation ? continuation.resume : null];
const responses = contexts.flatMap(context => {
const karama = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null;
return [context?.response, karama?.use.kind === 'cancel' ? karama.use.response : null];
}).filter((response): response is ResponseWindow => !!response);
const decisions = homeworldSavedDecisions(g);
const shipment = g.pendingShipment;
if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
requireRule(shipment, 'The saved No-Field response lost its original shipment.');
validateLeaderSkillNoFieldShipment(g, shipment);
const initial = responses.filter(r => r.kind === 'richeseNoField');
const guild = decisions.filter(d => d.kind === 'guildShipment');
requireRule(initial.length + guild.length === 1 &&
responses.every(r => r.noFieldSkillProof === undefined || r.kind === 'richeseNoField') &&
decisions.every(d => !('noFieldSkillProof' in d) || d.kind === 'guildShipment'),
'The saved No-Field shipment lost its unique cancellation response or Guild decision.');
for (const response of initial) requireRule(response.owner === shipment.player &&
response.noFieldSkillProof === shipment.noFieldSkillProof,
'The No-Field response changed its original declaration.');
for (const decision of guild) validateGuildShipmentDecision(g, decision);
return true;
}
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
if (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment.noField)) validateLeaderSkillNoFieldShipment(g, shipment);
if (shipment.smuggler) requireRule(
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
@@ -17271,6 +17355,7 @@ function validateGuildShipmentDecision(
getPlayer(g, shipment.player).faction !== 'fremen' &&
decision.territory === shipment.territory &&
decision.sector === shipment.sector &&
decision.noFieldSkillProof === shipment.noFieldSkillProof &&
decision.amount === (shipment.alliedNoField ? 1 : shipment.amount),
'The Guild decision does not match the pending shipment.',
);
@@ -17302,10 +17387,11 @@ function offerShipment(g: Game, shipment: PendingShipment) {
territory: to,
sector: s,
amount: n,
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}),
};
log(
g,
`${p.name} declared a shipment of ${n} forces to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
`${p.name} declared a shipment of ${shipment.noField ? `one concealed No-Field${shipment.smugglerCompanion ? ' and one free Smuggler force' : ''}` : `${n} forces`} to ${territory(to).name}, sector ${s}.${shipment.nexusEvent ? ` Richese Secret Ally is spent; the shipment is priced as one force (${shipment.cost} spice) if allowed.` : ''}`,
);
} else commitShipment(g, shipment);
}
@@ -17409,6 +17495,10 @@ function commitShipment(g: Game, shipment: PendingShipment) {
.join('; ');
if (n > 0) place(p, to, s, n, elite);
}
if (shipment.smugglerCompanion) {
withdrawNativeReserves(g, p, 1, 0);
place(p, to, s, 1, 0);
}
if (advisors && n > 0) (p.advisors ??= {})[to] ??= {};
p.shipped = true;
if (p.faction !== 'fremen') techIncome(g, 'heighliners', p);
@@ -17429,7 +17519,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
shipment.alliedNoField
? `${getPlayer(g, shipment.alliedNoField.owner).name} shipped ${p.name} with No-Field ${quoteAlliedTokenValue(g, shipment.alliedNoField)}, immediately placing ${n} physical forces (${elite} elite) in ${territory(to).name}, sector ${s}. ${shipment.alliedNoField.payer === 'both' ? 'Each ally paid 1 spice' : `${getPlayer(g, shipment.alliedNoField.payer).name} paid ${cost} spice`}.`
: shipment.noField
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. It counts as one force; physical reserves remain unchanged until reveal.`
? `${p.name} shipped one concealed No-Field to ${territory(to).name}, sector ${s}, for ${cost} spice. ${shipment.smugglerCompanion ? 'One free Smuggler force left reserves and arrived beside the marker; the concealed token remains unchanged until reveal.' : 'It counts as one force; physical reserves remain unchanged until reveal.'}`
: `${p.name} shipped ${n} forces to ${territory(to).name}, sector ${s}.${shipment.guildSecretEvent ? ` Guild Secret Ally is spent; Guild shipping prices cost ${cost} spice paid to the bank.` : ''}${shipment.nexusEvent ? ` Richese Secret Ally priced the ${n} physical forces as one: ${cost} spice.` : ''}${homeworldOrigins ? ` Homeworld sources: ${homeworldOrigins}. Total shipment cost: ${cost} spice.` : ''}`,
shipment.noField || shipment.alliedNoField
? { faction: 'richese', name: 'No-Field shipment' }
@@ -17460,7 +17550,7 @@ function commitShipment(g: Game, shipment: PendingShipment) {
p,
to,
s,
shipment.alliedNoField ? Math.max(1, n) : n,
shipment.alliedNoField ? Math.max(1, n) : n + (shipment.smugglerCompanion ? 1 : 0),
elite,
'shipment',
);
@@ -21749,8 +21839,10 @@ function applyActionInner(
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 smugglerCompanion = action.smuggler === true && noField && !guildSecretEvent && !nexusEvent
? quoteSmugglerNoField(g, id, to) : null;
requireRule(action.smuggler !== true || smuggler || smugglerCompanion,
'Smuggler needs a living face-up native trainer and an empty territory, with two ordinary shipment forces or one reserve companion beside your No-Field.');
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 - (smuggler ? 1 : 0));
@@ -21772,6 +21864,7 @@ function applyActionInner(
const allyPayment = contribution(g, p, cost, action.allyPayment);
const shipment: PendingShipment = {
...(smuggler ? { smuggler } : {}),
...(smugglerCompanion ? { smugglerCompanion } : {}),
...(currentGuildCunning(g,id) ? {guildNexusEvent:currentGuildCunning(g,id)!.receipt.event} : {}),
...(nexusEvent ? {nexusEvent} : {}),
...(guildSecretEvent ? {guildSecretEvent} : {}),
@@ -21792,15 +21885,18 @@ function applyActionInner(
}),
...(noField ? { noField } : {}),
};
if (noField && g.leaderSkills) shipment.noFieldSkillProof = noFieldSkillProof(shipment);
if (noField && g.leaderSkills) validatePhysicalShipment(g, shipment);
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
bindGuildCunningShipment(g,p,'reserve',shipment);
if (shipment.noField) {
checkShipmentIncomeRounding(g, p, cost, allyPayment);
g.pendingShipment = shipment;
g.response = { kind: 'richeseNoField', owner: p.id, passed: [] };
g.response = { kind: 'richeseNoField', owner: p.id, passed: [],
...(shipment.noFieldSkillProof ? { noFieldSkillProof: shipment.noFieldSkillProof } : {}) };
log(
g,
`${p.name} declared a concealed No-Field shipment to ${territory(to).name}, sector ${s}.`,
`${p.name} declared a concealed No-Field shipment${smugglerCompanion ? ' with one free Smuggler force' : ''} to ${territory(to).name}, sector ${s}.`,
);
} else offerShipment(g, shipment);
return g;
@@ -23146,13 +23242,16 @@ export function viewGame(state: Game, id: string) {
? { ...g.decision, leader: '', owner: '' }
: g.decision?.kind === 'faceDance'
? { ...g.decision, ...(faceDanceReturnBlock(g, g.decision.winner) ? {blocked: faceDanceReturnBlock(g, g.decision.winner)!} : {}) }
: (g.decision ?? null),
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
? { ...g.decision, noFieldSkillProof: undefined }
: (g.decision ?? null),
response: g.response
? {
...g.response,
...(g.response.guildContributions ? { guildContributions: undefined } : {}),
...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
...(g.response.sandmasterProof ? { sandmasterProof: undefined } : {}),
...(g.response.noFieldSkillProof ? { noFieldSkillProof: undefined } : {}),
passed: g.response.passed.includes(id) ? [id] : [],
...(g.response.kind === 'revivalIncome' &&
![g.response.owner, g.response.recipient].includes(id)
+5 -5
View File
@@ -3576,18 +3576,18 @@ export const RULE_TOPICS: RuleTopic[] = [
'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. Multiple piles and 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.',
'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. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'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 remains unfinished.',
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and all 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. Smuggler normal reserve shipping and optional Sandmaster ground-route collection are connected. Spice Banker lower spending is connected; normal income, other remaining bands, 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/spice-banker.ts','game/sandmaster-movement.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. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. 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/spice-banker.tsx','components/sandmaster-movement.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 shipments, Sandmaster route collection and funded Banker commitments. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ 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 and optional Sandmaster ground-route collection are connected. Spice Banker lower spending is connected; normal income, other remaining bands, 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/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.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. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. 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/spice-banker.tsx','components/sandmaster-movement.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, Sandmaster route collection and funded Banker commitments. 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','docs/SPICE_BANKER_RUNTIME.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 and Banker funding, death, traitor, privacy and concurrent settlement 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','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx'] },
{ 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 and Banker funding, death, traitor, privacy and concurrent settlement 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','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx'] },
],
},
...[
+39
View File
@@ -0,0 +1,39 @@
import type { Game, GameView } from './engine';
import { quoteSmugglerShipment } from './smuggler-shipment';
/** The No-Field remains the priced marker; this is one separate physical force. */
export type SmugglerNoFieldCompanion = { leader: string; amount: 1 };
export function smugglerNoFieldModeSupported(g: Game | GameView): boolean {
return (
g.expansions.length === 1 &&
g.expansions[0] === 'choam' &&
!g.homeworlds &&
!g.nexusCards &&
!g.discoveries &&
!('discoveryEnabled' in g && g.discoveryEnabled) &&
!g.strongholdCards &&
!g.techTokens
);
}
/** Public custody only: the hidden token value never affects this free companion. */
export function quoteSmugglerNoField(
g: Game | GameView,
player: string,
destination: string,
): SmugglerNoFieldCompanion | null {
const p = g.players.find((p) => p.id === player);
if (
!p ||
p.faction !== 'richese' ||
p.reserves < 1 ||
!Number.isSafeInteger(p.reserves) ||
!smugglerNoFieldModeSupported(g)
)
return null;
// Reuse the normal band's living/native/face-up/whole-territory emptiness checks.
// Two denotes one priced marker with one free companion, not a reserve withdrawal of two.
const skill = quoteSmugglerShipment(g, player, destination, 2);
return skill ? { leader: skill.leader, amount: 1 } : null;
}