+ Plan funding: {ownSupport} own spice for force support + {commitment}{' '}
+ committed to Spice Banker = {ownSupport + commitment} own spice total.
+
+
+ You may commit up to {state.maximum} own spice after your own
+ force-support payment. Bank and CHOAM support are separate. Each
+ committed spice adds 1 strength if this skilled leader survives. The
+ commitment goes to the Bank after a win or loss, including if the leader
+ dies. A sole winner through a traitor call spends no Battle Plan spice.
+
+
+ );
+}
diff --git a/docs/COMPONENT_INVENTORY.md b/docs/COMPONENT_INVENTORY.md
index a4fc003b..75fe8a1b 100644
--- a/docs/COMPONENT_INVENTORY.md
+++ b/docs/COMPONENT_INVENTORY.md
@@ -9,8 +9,9 @@ Planetologist now also has movement and base green-Special battle controls;
through readable rescue choices without creating counters. [Battle effects](LEADER_BATTLE_EFFECTS.md)
now connect Rihani inspection/exchange, Mentat/Bureaucrat scoring and Sandmaster
victory spice. [Smuggler shipping](SMUGGLER_SHIPMENT.md) now connects its normal reserve bonus
-without adding any physical component. Other unfinished bands, Spice Banker and Diplomat remain
-missing. This supersedes the historical missing-inventory
+without adding any physical component. [Spice Banker battle spending](SPICE_BANKER_RUNTIME.md) uses a separate sealed
+spice amount and the same original skill inspector. Other unfinished bands and
+Diplomat remain missing. This supersedes the historical missing-inventory
rows below only for Leader Skills. See [source inventory](LEADER_SKILLS_RULES.md),
[capture interpretation](LEADER_SKILLS_CAPTURE.md) and [prototype evidence](LEADER_SKILLS_RUNTIME.md).
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 0425e094..59f80533 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -4,6 +4,12 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
## Evidence and source of truth
+- [Spice Banker battle spending](SPICE_BANKER_RUNTIME.md) connects sealed own
+ spice, separate support funding, survivor strength, traitor payment precedence,
+ private controls, all four AI profiles and saved resolution. Normal income
+ remains pending the recorded custody question. Broad results and browser
+ evidence are recorded in the checkpoint; no mode gate is opened.
+
- [Smuggler reserve shipment](SMUGGLER_SHIPMENT.md) connects the optional free
companion, total physical count, Guild rounding, controls, all four AI profiles
and saved interception. Independent review added missing-decision rejection.
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index a6bb2308..3c61a00d 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,27 @@
# Dune implementation status
+## 13 September 2026 — Spice Banker battle commitment prototype
+
+[Spice Banker](SPICE_BANKER_RUNTIME.md) connects one through three sealed own
+spice to a selected skilled leader's surviving battle strength. Funds remain
+separate from force support and unavailable for other spending. A sole successful
+traitor caller keeps the commitment; other outcomes pay the Bank even on leader
+death, while death removes the bonus. Printed disc values and bounties stay fixed.
+
+Owner/full-plan inspection and public reveal display the amount at their proper
+times; all four AI profiles submit funded choices. Independent source and
+persistence review, private SQLite restart, rejected diversion, concurrent final
+resolution and legacy omitted-field continuation cover this boundary. Two seeded
+Basic/Advanced samples finished without rejected candidates but did not use
+Banker spending; targeted tests establish the effect. The browser checked a
+three-spice commitment, pending refresh, actual +3 strength and payment, then
+restored one board force, fifteen reserves, four Tanks, two spice and the same
+private cards. Broad results and final preservation are in the checkpoint commit.
+
+Normal income remains pending a user ruling about already-earned public spice
+when the trainer dies or is captured before Mentat Pause. Combined modules and
+complete skill acceptance remain unfinished; no mode/publication gate is opened.
+
## 13 September 2026 — Smuggler reserve shipment prototype
[Smuggler shipping](SMUGGLER_SHIPMENT.md) connects one optional free accompanying
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index 9d6a2675..659ab818 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -51,8 +51,9 @@ publisher instruction to announce a name.
[Battle-effects follow-up](LEADER_BATTLE_EFFECTS.md) connects both Rihani bands
and the lower Mentat, Bureaucrat and Sandmaster effects. Their normal questioning,
-payments and movement collection remain missing, along with all Spice Banker,
-Diplomat effects and Smuggler battle collection. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
+payments and movement collection remain missing, along with Spice Banker normal
+income, Diplomat effects and Smuggler battle collection. [Banker battle spending](SPICE_BANKER_RUNTIME.md)
+now connects its lower band with separate sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
connects its optional normal reserve bonus. Planetologist and Suk Graduate have the connected
prototypes described below.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index b469a923..494c30d9 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -6,6 +6,7 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
| Topic | Decision to preserve | Detailed authority and evidence |
| --- | --- | --- |
+| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. No-Field plus a separate free force is officially permitted but still awaits integration. Legacy unstamped declarations keep their quoted full price. | [Source contract and prototype](SMUGGLER_SHIPMENT.md) |
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Multiple piles and Face Dancer composition stay guarded. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md) |
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
@@ -33,7 +34,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
| Semuta Drug | Full-hand take-before-discard and the privacy-neutral immediate reaction protocol remain unresolved. Typed discard continuations already exist; they are not activation. | [Source update](SEMUTA_SOURCE_UPDATE_20260907.md), [privacy review](SEMUTA_PRIVACY_REVIEW.md) |
-| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody, Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat retreat ordering and simultaneous optional timing need resolution before those cases are enabled. These are newly recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources) |
+| Leader Skills edge cases | Capture/replacement eligibility, Banker escrow custody (user question sent), Smuggler survival settlement, Mentat empty-hand/death disclosure, Diplomat retreat ordering and simultaneous optional timing need resolution before those cases are enabled. These are newly recorded source gaps, not answered user rulings. | [Material boundaries](LEADER_SKILLS_RULES.md#material-boundaries-not-settled-by-the-sources) |
| Suk Graduate / Atreides | Whether rescued dialed casualties count toward the seven-loss Kwisatz Haderach threshold remains unresolved. The user question has been sent; Advanced Atreides rescue stays guarded. | [Source and runtime boundary](SUK_GRADUATE_RULES.md) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md
new file mode 100644
index 00000000..213bae91
--- /dev/null
+++ b/docs/SPICE_BANKER_RUNTIME.md
@@ -0,0 +1,83 @@
+# Spice Banker battle commitment prototype
+
+13 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
+the lower battle band. The normal income band and combined-module acceptance
+remain unfinished; public mode starts and publication stay gated.
+
+## Source and payment contract
+
+The physical card permits spending one through three own spice with the Battle
+Plan to add the same amount to the selected leader's strength, independently of
+force support. A captured skilled leader can supply this battle band with the
+captor's spice. The common skill rules remove the bonus if that leader dies.
+See [GF9 CHOAM & Richese rules and Q&A](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf),
+pages 8–9 and 12, and [Jack Reda's walkthrough](https://www.youtube.com/watch?v=XT_azRVLq_0&t=1029s),
+17:09–17:37. The existing physical card image and sourced full paraphrase are
+retained; this creates no new card, resource or leader identity.
+
+The [core rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
+provides the payment precedence in its Traitor and Advanced Combat sections:
+a sole successful traitor caller loses nothing; otherwise committed Battle Plan
+spice goes to the Bank on either outcome. The prototype therefore spends Banker
+spice even on leader death or traitor loss, and waives it for a sole traitor-call
+winner. Death removes the strength bonus, not the already chosen plan cost.
+This is an explicit composition of the card and core payment rules, not a
+separately located Banker death FAQ. It supersedes the earlier private research
+suggestion that death should release the commitment. Printed disc strength,
+Zoal's copied value and leader bounty are unchanged.
+
+The normal band is mandatory once per phase when another player makes one
+payment of at least four spice to the Bank; multiple smaller payments do not
+combine. Revival qualifies. Earned spice waits in front of the shield until
+Mentat Pause. Neither publisher nor designer material located in the renewed
+search settles custody of that accumulated income if the trainer dies or is
+captured first. The user has been asked whether to retain the pending boundary
+or rule that already-earned spice stays with the original faction. No answer
+or income implementation is assumed here.
+
+## Connected behavior
+
+`bankerSpice` is an optional sealed-plan amount; absence or zero declines.
+The engine validates an actual living controlled skilled disc, its battle
+posture, integer range and own funds remaining after force support. Ally aid
+and Stronghold support cannot fund this separate commitment. Saved plans reserve
+the amount against bribes and other spending. The same checks run before views,
+automatic normalization and actions. Legacy plans retain zero Banker spending.
+
+The battle quote computes a separate payment alongside the existing support
+settlement and a surviving-leader score bonus. It does not add Banker spice to
+CHOAM's force-support share. Payment and resolution commit atomically through
+the existing versioned room write, with an automatic explanatory history entry.
+
+The battle panel distinguishes support and Banker amounts and shows the combined
+own commitment. The owner and authorized full-plan inspector can see the sealed
+amount; rivals see it only when both plans are public. All four AI profiles use
+the public skill and private own budget to submit funded plans. This is legal
+prototype behavior, not calibrated Banker strategy.
+
+## Evidence and remaining scope
+
+Focused engine checks cover one through three spice, win/loss, death, traitor
+win/loss/mutual calls, captured-leader funds, printed values, own-support funding,
+private views, malformed saved fields, JSON restoration and all four profiles.
+SQLite checks cover reserved-spice diversion, private restart, concurrent final
+resolution, exact waiver/bounty and legacy absence. Controls and inspector
+checks retain the private/public boundary. Independent rules, persistence and
+UI review cover this supported boundary.
+
+Two genuine four-profile Basic/Advanced samples completed with 99/212 actions,
+3/6 JSON restorations and no rejected candidates. Their first seat explicitly
+selected its offered Banker, but neither game used Banker spending. These are
+continuation samples; the targeted tests establish the effect. A fresh browser room
+assigned Banker through genuine setup, followed by a conserved staged battle.
+The player selected Hasimir Fenring, Baliset, four dialed forces and three Banker
+spice. Pending refresh restored that commitment with five spice still held.
+Settlement paid three, added three strength and left one Arrakeen force,
+fifteen reserves, four Tanks and two spice. Another refresh retained those
+values and the same private Crysknife, Baliset and Burseg while card cleanup
+awaited the player. This targeted scenario is not a complete human game.
+Required broad results are recorded with the checkpoint.
+
+Remaining work includes normal income, all economic trigger routes and ordering,
+its public deferred custody, combined modules, wider full-game interaction and
+strategy refinement. The existing development gates remain unchanged.
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index 49a02517..07928a17 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -29,6 +29,7 @@ import {
import { matchingTraitor } from './traitors';
import { auditCount } from './choam-auditor';
import { sukGraduateSkill, type SukGraduateSkill } from './suk-graduate';
+import { validateSpiceBankerSpend } from './spice-banker';
import { rihaniVictorySkill, type RihaniSkill } from './rihani-decipherer';
import {
quoteHomeworldBattleRules,
@@ -53,6 +54,7 @@ export class BattleResolutionQuoteError extends Error {
}
}
export type ResolutionPlan = {
+ bankerSpice?: number;
dial: number;
support: number;
allyPayment?: number;
@@ -136,6 +138,7 @@ export type BattleResolutionQuote = {
effects: ReturnType | null;
explosion: boolean;
payments: BattleSupportPayment[];
+ spiceBankerPayments?: { player: string; amount: number; waivedByTraitor: boolean }[];
choamIncome: { owner: string; amount: number } | null;
bounty: { player: string; amount: number } | null;
strongholdIncome: { player: string; amount: number }[];
@@ -197,6 +200,12 @@ function validateCombatant(
whole(p.allyPayment ?? 0),
'The revealed battle needs valid force, dial and payment quantities.',
);
+ try {
+ validateSpiceBankerSpend(side.leaderSkills ?? [], side.leader && !side.leader.dead ? side.leader.id : undefined,
+ p.bankerSpice, side.spice, strongholdSupportCost(side.stronghold, p.support) - (p.allyPayment ?? 0));
+ } catch (error) {
+ throw new BattleResolutionQuoteError(error instanceof Error ? error.message : 'Invalid Spice Banker commitment.');
+ }
const selected = [p.weapon, p.defense, p.leader, side.lateDefense].filter(
(value): value is string => value != null,
);
@@ -406,6 +415,10 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
(v) => v.called && !a.plan.kwisatz && v.beneficiary === d.id,
);
const payments: BattleSupportPayment[] = [];
+ const spiceBankerPayments = [a, d].filter(side => (side.plan.bankerSpice ?? 0) > 0).map(side => {
+ const waivedByTraitor = side === a ? ac && !dc : dc && !ac;
+ return { player: side.id, amount: waivedByTraitor ? 0 : side.plan.bankerSpice!, waivedByTraitor };
+ });
const credits = new Map();
if (input.advanced)
for (const [side, free] of [
@@ -524,6 +537,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
weapon: aw,
defense: ad,
skilledLeaderSurvives: !deaths.attacker,
+ bankerSpice: a.plan.bankerSpice,
}),
defender: leaderSkillBattleBonus({
assignments: d.leaderSkills ?? [],
@@ -531,6 +545,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
weapon: dw,
defense: dd,
skilledLeaderSurvives: !deaths.defender,
+ bankerSpice: d.plan.bankerSpice,
}),
};
const attackerPenalty = bureaucratBattlePenalty(d.leaderSkills ?? [], d.leader?.id,
@@ -759,6 +774,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
effects,
explosion,
payments,
+ ...(spiceBankerPayments.length ? { spiceBankerPayments } : {}),
choamIncome:
choam && choamAmount > 0
? { owner: choam.id, amount: choamAmount }
diff --git a/game/bots.ts b/game/bots.ts
index e275638e..6ca05ba9 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -66,6 +66,7 @@ import {
} from './shrine';
import { reserveShipmentCost } from './shipment-price';
import { quoteSmugglerShipment } from './smuggler-shipment';
+import { spiceBankerBattleMaximum, spiceBankerModeSupported } from './spice-banker';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote } from './nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from './nexus-richese-options';
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
@@ -551,6 +552,9 @@ function ornithopterMoves(g: GameView): Action[] {
}
function candidatePlan(a: Action): Plan {
return {
+ ...(Number(a.bankerSpice ?? 0)
+ ? { bankerSpice: Number(a.bankerSpice) }
+ : {}),
dial: Number(a.dial),
support: Number(a.support ?? 0),
leader: typeof a.leader === 'string' ? a.leader : null,
@@ -676,6 +680,7 @@ function plans(g: GameView): Action[] {
(element) => element.field !== field || element.value === value,
);
const battleSkills = g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [];
+ const bankerPrototypeAvailable = spiceBankerModeSupported(g);
const specialForLeader = (card: Card | undefined, leader: string | null) =>
canUsePlanetologistBattleSpecial({assignments:battleSkills, selectedLeader:leader, card});
const weapons = [
@@ -818,7 +823,7 @@ function plans(g: GameView): Action[] {
const enemySurvivingStrength =
effects.stunned || (inspected && effects.defenderDead)
? 0
- : expectedEnemyStrength;
+ : expectedEnemyStrength + (inspected?.plan.bankerSpice ?? 0);
const opponentPenalty = stoneBattle ? 0 : bureaucratBattlePenalty(
battleSkills, myLeader?.id, !effects.attackerDead,
leaderSkillStrongholdCount(g, other),
@@ -843,6 +848,7 @@ function plans(g: GameView): Action[] {
ideal,
...bankSupportedDials,
...freeEliteDials,
+ ...[1, 2, 3].map((bonus) => Math.max(0, ideal - bonus)),
0,
...(typedForces ? [0.5] : []),
Math.max(0, ownStrength - 1),
@@ -866,6 +872,32 @@ function plans(g: GameView): Action[] {
: null;
if (typedForces && !commitment) continue;
if (commitment) action.support = commitment.support;
+ const supportCost = Math.max(
+ 0,
+ Number(action.support ?? 0) -
+ (b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0),
+ );
+ const ordinaryAllyPayment = Math.max(
+ 0,
+ supportCost - (me.spice ?? 0),
+ );
+ const ownSupportCost = Math.max(
+ 0,
+ supportCost - ordinaryAllyPayment,
+ );
+ const bankerSpice = Math.min(
+ level + 1,
+ 3,
+ bankerPrototypeAvailable
+ ? spiceBankerBattleMaximum(
+ battleSkills,
+ myLeader?.id,
+ me.spice ?? 0,
+ ownSupportCost,
+ )
+ : 0,
+ );
+ if (bankerSpice) action.bankerSpice = bankerSpice;
const stonePlan = isStoneBurner(
me.hand?.find((c) => c.id === action.weapon),
);
@@ -938,18 +970,25 @@ function plans(g: GameView): Action[] {
(effectiveDefense ? 2 : 0)
: level === 0
? variation(g, `${leader}-${weapon}-${defense}-${dial}`) * 100
- : ownSurvivingStrength * 2 + opponentPenalty * 2 +
+ : (ownSurvivingStrength +
+ (!effects.attackerDead && !effects.stunned
+ ? bankerSpice
+ : 0)) * 2 +
+ opponentPenalty * 2 +
(inspected && enemySurvivingStrength === 0 ? 10 : 0) +
effectiveWeapon +
effectiveDefense +
(safeLeader && level >= 2 ? 5 : 0) -
- Math.abs(Number(action.dial) - ideal) * 2 +
+ Math.abs(
+ Number(action.dial) - Math.max(0, ideal - bankerSpice),
+ ) * 2 +
(correctDefense ? 10 : 0) -
(selfExplosion || explosionRisk ? 80 : 0) -
(exposedLeader ? 100 : 0);
options.push({
action,
- score: score - (commitment?.cost ?? 0) * 0.2,
+ score:
+ score - (commitment?.cost ?? 0) * 0.2 - bankerSpice * 0.2,
});
}
}
diff --git a/game/engine.ts b/game/engine.ts
index ffb21915..b33f7f1d 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -11,6 +11,7 @@ import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-ski
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 { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
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';
@@ -561,6 +562,8 @@ export type Player = {
prediction?: { faction: FactionId; turn: number };
};
export type Plan = {
+ /** Separate own-spice commitment for the selected Spice Banker; absent is zero. */
+ bankerSpice?: number;
dial: number;
leader: string | null;
weapon: string | null;
@@ -571,7 +574,7 @@ export type Plan = {
};
export type PlanField = Exclude<
keyof Plan,
- 'support' | 'kwisatz' | 'allyPayment'
+ 'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice'
>;
export type Battle = {
/** Public face-up/behind-shield choice precedes faction battle powers. */
@@ -4917,6 +4920,15 @@ function leaderSkillAssignmentUnavailable(
function leaderSkillsIntegrity(g: Game) {
sukRescueIntegrity(g);
rihaniIntegrity(g);
+ for (const [owner, plan] of Object.entries(g.battle?.plans ?? {})) {
+ if (plan.bankerSpice === undefined) continue;
+ const p = getPlayer(g, owner);
+ if (plan.bankerSpice) requireRule(spiceBankerModeSupported(g), 'Spice Banker with expansion modules is still being integrated.');
+ const leader = controlledLeaders(g, p).find(l => l.id === plan.leader && !l.dead);
+ requireRule([g.battle!.attacker, g.battle!.defender].includes(owner), 'Spice Banker belongs to a current combatant.');
+ 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) {
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'guildShipment');
requireRule(decisions.length === (g.pendingShipment ? 1 : 0),
@@ -6950,6 +6962,7 @@ function uncommittedSpice(g: Game, p: Player) {
: 0) -
(battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) -
(g.battle?.plans[p.id]?.allyPayment ?? 0)) -
+ (g.battle?.plans[p.id]?.bankerSpice ?? 0) -
(g.pendingHomeworldShipment?.player === p.id
? g.pendingHomeworldShipment.cost - g.pendingHomeworldShipment.allyPayment : 0)
);
@@ -12723,6 +12736,9 @@ function validatePlan(
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const forces = combatForces(g, p, b.territory, opponent);
const typedForces = g.advanced || p.faction === 'ixians';
+ const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice;
+ requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3,
+ 'Choose zero to decline, or one through three spice for Spice Banker.');
const dial = typedForces
? Number(input.dial)
: integer(input.dial, 0, forces.normal + forces.elite, 'Forces dialed');
@@ -12737,12 +12753,13 @@ function validatePlan(
);
const allyPayment = integer(
input.allyPayment ??
- Math.max(0, battleSupportCost(g, p, support) - p.spice),
- Math.max(0, battleSupportCost(g, p, support) - p.spice),
+ Math.max(0, battleSupportCost(g, p, support) - (p.spice - bankerSpice)),
+ Math.max(0, battleSupportCost(g, p, support) - (p.spice - bankerSpice)),
Math.min(battleSupportCost(g, p, support), battleAidFor(g, p)?.amount ?? 0),
'Battle ally payment',
);
const plan: Plan = {
+ ...(bankerSpice ? { bankerSpice } : {}),
...(allyPayment ? { allyPayment } : {}),
dial,
leader:
@@ -12777,6 +12794,10 @@ function validatePlan(
(l) => !l.dead && (!l.usedAt || l.usedAt === b.territory),
);
const l = available.find((l) => l.id === plan.leader);
+ nexusRule(() => validateSpiceBankerSpend(battleLeaderSkills(g, p), l?.id, bankerSpice,
+ p.spice, battleSupportCost(g, p, support) - allyPayment));
+ if (bankerSpice) requireRule(spiceBankerModeSupported(g),
+ 'Spice Banker with expansion modules is still being integrated.');
requireRule(!l || !g.leaderSkills?.assignments.some((assignment) => assignment.owner === p.id && assignment.leader === l.id) || !!b.leaderSkillHidden?.[p.id], 'Move your skilled leader behind the shield before using it in your battle plan.');
const hero = cardOf(p, plan.leader)?.kind === 'hero';
requireRule(
@@ -14229,6 +14250,14 @@ function resolveBattle(g: Game) {
{ faction: player.faction, name: 'Stronghold support' },
);
}
+ for (const payment of quote.spiceBankerPayments ?? []) {
+ const player = getPlayer(g, payment.player);
+ player.spice -= payment.amount;
+ log(g, payment.waivedByTraitor
+ ? `${player.name}'s successful traitor call waived the committed Spice Banker payment along with other Battle Plan spice.`
+ : `${player.name} paid ${payment.amount} committed Spice Banker spice to the bank, separate from force support. The payment is spent win or lose; only a surviving skilled leader receives its strength bonus.`,
+ { faction: player.faction, name: 'Spice Banker payment' });
+ }
if (quote.choamIncome) g.pendingChoamBattleIncome = { ...quote.choamIncome };
const explosion = quote.explosion;
if (ac && dc) {
diff --git a/game/leader-skill-combat.ts b/game/leader-skill-combat.ts
index 80c28480..8901cc1d 100644
--- a/game/leader-skill-combat.ts
+++ b/game/leader-skill-combat.ts
@@ -24,7 +24,7 @@ export type BattleLeaderSkill = {
};
export type AppliedLeaderSkill = {
- skill: DirectLeaderSkillId | 'planetologist' | 'mentat';
+ skill: DirectLeaderSkillId | 'planetologist' | 'mentat' | 'spice-banker';
amount: 1 | 2 | 3;
mode: 'normal' | 'skilled';
};
@@ -165,9 +165,13 @@ export function leaderSkillBattleBonus(input: {
weapon: Card | undefined;
defense: Card | undefined;
skilledLeaderSurvives: boolean;
+ bankerSpice?: number;
}): LeaderSkillBattleBonus {
if (!input.selectedLeader) return { bonus: 0, applied: [] };
const applied: AppliedLeaderSkill[] = [];
+ if (input.bankerSpice && Number.isSafeInteger(input.bankerSpice) && input.bankerSpice >= 1 && input.bankerSpice <= 3 &&
+ input.selectedLeader.kind === 'disc' && usesSurvivingSkilledLeader(input.assignments, 'spice-banker', input.selectedLeader.id, input.skilledLeaderSurvives))
+ applied.push({ skill: 'spice-banker', amount: input.bankerSpice as 1 | 2 | 3, mode: 'skilled' });
if (input.selectedLeader.kind === 'disc' && usesSurvivingSkilledLeader(
input.assignments, 'mentat', input.selectedLeader.id, input.skilledLeaderSurvives,
)) applied.push({ skill: 'mentat', amount: 2, mode: 'skilled' });
diff --git a/game/reference.ts b/game/reference.ts
index bd1b2f2d..25864147 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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, Rihani exchanges and optional Smuggler shipping 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, optional Smuggler shipping and sealed Spice Banker battle spending 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.',
@@ -3576,16 +3576,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
- '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.',
+ '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 and Sandmaster movement collection, 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 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'] },
+ { 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. 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/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. 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/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 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'] },
],
},
...[
diff --git a/game/spice-banker.ts b/game/spice-banker.ts
new file mode 100644
index 00000000..3d7d6376
--- /dev/null
+++ b/game/spice-banker.ts
@@ -0,0 +1,72 @@
+import {
+ usesSurvivingSkilledLeader,
+ type BattleLeaderSkill,
+} from './leader-skill-combat';
+
+export class SpiceBankerError extends Error {}
+
+/** The same development boundary applies to declarations, saved plans, controls and bots. */
+export function spiceBankerModeSupported(game: {
+ expansions: readonly string[];
+ homeworlds?: unknown;
+ nexusCards?: unknown;
+ discoveries?: unknown;
+ discoveryEnabled?: boolean;
+ strongholdCards?: unknown;
+ techTokens?: unknown;
+}): boolean {
+ return (
+ !game.expansions.length &&
+ !game.homeworlds &&
+ !game.nexusCards &&
+ !game.discoveries &&
+ !game.discoveryEnabled &&
+ !game.strongholdCards &&
+ !game.techTokens
+ );
+}
+
+export function spiceBankerBattleMaximum(
+ assignments: readonly BattleLeaderSkill[],
+ leader: string | null | undefined,
+ ownSpice: number,
+ ownSupport = 0,
+): number {
+ if (!usesSurvivingSkilledLeader(assignments, 'spice-banker', leader, true))
+ return 0;
+ if (
+ !Number.isSafeInteger(ownSpice) ||
+ !Number.isSafeInteger(ownSupport) ||
+ ownSpice < 0 ||
+ ownSupport < 0
+ )
+ return 0;
+ return Math.max(0, Math.min(3, ownSpice - ownSupport));
+}
+
+/** Banker funds are separate from force support and remain behind the shield until settlement. */
+export function validateSpiceBankerSpend(
+ assignments: readonly BattleLeaderSkill[],
+ leader: string | null | undefined,
+ amount: unknown,
+ ownSpice: number,
+ ownSupport = 0,
+): number {
+ const spend = amount === undefined ? 0 : amount;
+ if (
+ typeof spend !== 'number' ||
+ !Number.isSafeInteger(spend) ||
+ spend < 0 ||
+ spend > 3
+ )
+ throw new SpiceBankerError(
+ 'Choose zero to decline, or one through three spice for Spice Banker.',
+ );
+ if (
+ spend > spiceBankerBattleMaximum(assignments, leader, ownSpice, ownSupport)
+ )
+ throw new SpiceBankerError(
+ 'Spice Banker needs the selected skilled leader and enough own spice after force support.',
+ );
+ return spend;
+}
diff --git a/tests/leader-skill-battle-fixture.ts b/tests/leader-skill-battle-fixture.ts
index 5945f0da..b86e0d79 100644
--- a/tests/leader-skill-battle-fixture.ts
+++ b/tests/leader-skill-battle-fixture.ts
@@ -25,7 +25,7 @@ export function advancedAtreidesSukOffer(): Game {
}
/** Genuine module setup, then a conserved, explicitly staged battle position. */
-export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
+export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, unsealed = false, hide = true, advanced = false, elite = false, bluff = false, captured = false, capturedNativeSkill = undefined as LeaderSkillId | undefined, weaponKills = false, dial = 4, atreides = false, territory = 'arrakeen', sector = 10 } = {}): Game {
const native = captured ? 'harkonnen' : atreides ? 'atreides' : 'emperor';
let game = dealtSkillGame(native, advanced, skill);
for (const id of ['a', 'd']) {
@@ -89,6 +89,7 @@ export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, hid
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
+ if (unsealed) return game;
game = applyAction(game, 'a', { type: 'battlePlan', dial, support: advanced ? 3 : 0,
leader: captured ? 'guild-0' : hide && !bluff ? `${native}-0` : `${native}-1`, weapon: played.id });
game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'guild-1', weapon: enemyWeapon });
diff --git a/tests/spice-banker-controls.test.tsx b/tests/spice-banker-controls.test.tsx
new file mode 100644
index 00000000..885d0e0a
--- /dev/null
+++ b/tests/spice-banker-controls.test.tsx
@@ -0,0 +1,218 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { registerHooks } from 'node:module';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { SpiceBankerControl } from '../components/spice-banker';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ applyAction,
+ viewGame,
+ type GameView,
+ type Plan,
+} from '../game/engine';
+import { spiceBankerGame } from './spice-banker-fixture';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(
+ specifier === 'next/image' ? 'vinext/shims/image' : specifier,
+ context,
+ );
+ },
+});
+const { PrivateBattlePlan } = await import('../components/private-battle-plan');
+const { RevealedBattle } = await import('../components/revealed-battle');
+aliases.deregister();
+
+function renderControl(
+ game: GameView,
+ selectedLeader: string,
+ ownSupport: number,
+ value: number,
+) {
+ return renderToStaticMarkup(
+ createElement(SpiceBankerControl, {
+ game,
+ selectedLeader,
+ ownSupport,
+ value,
+ onChange() {},
+ }),
+ );
+}
+
+void test('Spice Banker renders a bounded separate own-spice commitment only for the controlled living skilled disc', () => {
+ const native = spiceBankerGame();
+ native.players[0].spice = 4;
+ const view = viewGame(native, 'a');
+ const html = renderControl(view, 'emperor-0', 2, 2);
+ assert.match(html, /aria-label="Spice Banker battle commitment"/);
+ assert.match(html, /id="battle-banker-spice"[^>]*max="2"[^>]*value="2"/);
+ assert.match(
+ html,
+ /Plan funding: 2 own spice for force support \+ 2 committed to Spice Banker = 4 own spice total/,
+ );
+ assert.match(
+ html,
+ /Each committed spice adds 1 strength if this skilled leader survives/,
+ );
+ assert.match(html, /commitment goes to the Bank after a win or loss/);
+ assert.match(
+ html,
+ /sole winner through a traitor call spends no Battle Plan spice/,
+ );
+ assert.equal(renderControl(view, 'emperor-1', 0, 3), '');
+
+ const faceUp = structuredClone(view);
+ faceUp.leaderSkills!.assignments.find(
+ (assignment) => assignment.skill === 'spice-banker',
+ )!.faceUp = true;
+ assert.equal(renderControl(faceUp, 'emperor-0', 0, 3), '');
+
+ const dead = structuredClone(view);
+ dead.players
+ .find((player) => player.id === 'a')!
+ .leaders.find((leader) => leader.id === 'emperor-0')!.dead = true;
+ assert.equal(renderControl(dead, 'emperor-0', 0, 3), '');
+
+ const foreign = structuredClone(view);
+ foreign.leaderSkills!.assignments.find(
+ (assignment) => assignment.skill === 'spice-banker',
+ )!.controller = 'd';
+ assert.equal(renderControl(foreign, 'emperor-0', 0, 3), '');
+
+ const combinedModule = structuredClone(view);
+ combinedModule.expansions = ['ix'];
+ assert.equal(renderControl(combinedModule, 'emperor-0', 0, 3), '');
+
+ const capturedGame = spiceBankerGame(false, true);
+ capturedGame.players[0].spice = 3;
+ const captive = viewGame(capturedGame, 'a');
+ assert.match(
+ renderControl(captive, 'guild-0', 0, 3),
+ /Spice Banker commitment/,
+ );
+});
+
+void test('full-plan inspection and public reveal show the exact commitment without leaking it to another private viewer', () => {
+ const plan: Plan = {
+ dial: 1,
+ support: 0,
+ bankerSpice: 3,
+ leader: 'emperor-0',
+ weapon: null,
+ defense: null,
+ };
+ const inspected = {
+ me: 'atreides',
+ players: [
+ { id: 'atreides', name: 'Atreides' },
+ { id: 'emperor', name: 'Emperor' },
+ { id: 'observer', name: 'Observer' },
+ ],
+ allLeaders: [],
+ battle: {
+ revealed: false,
+ fullPlan: { owner: 'atreides', target: 'emperor' },
+ fullPlanInsight: { target: 'emperor', plan, cards: [] },
+ },
+ } as unknown as GameView;
+ const privateHtml = renderToStaticMarkup(
+ createElement(PrivateBattlePlan, { game: inspected }),
+ );
+ assert.match(privateHtml, /Spice Banker commitment<\/dt>