Connect Spice Banker sealed battle spending and saved settlement

Prototype one through three own spice as a separate sealed battle commitment. Reserve funds alongside actual force support, add strength only for the surviving skilled leader, and settle once through existing versioned writes. Apply the documented card/core-rule interpretation: payment survives leader death or loss, except for the sole successful traitor caller. Keep printed disc strength and bounty unchanged. Normal income awaits its deferred-custody ruling; combined modules remain gated.

Validation: 54 focused tests; typecheck, lint, 4490 offline tests, production build and 43 HTTP tests passed. Independent rules, privacy, persistence and UI review completed. SQLite tests cover reserved-spice diversion, concurrent settlement, traitor waiver and legacy plans. Browser exercised a three-spice commitment, pending refresh, actual +3 strength and payment, and restored private cards and the next decision. Two genuine four-profile Basic/Advanced samples completed without rejected candidates but did not use Banker spending.

Following another reported outage, the server remained healthy. Preserved all 461 opening rooms and original seat/recovery rows (495 rooms after QA/HTTP). SQLite integrity and foreign keys passed. A fresh online backup captured all 495 rooms, and browser refresh restored the QA seat. No restart or reset was needed.

Verified source tree: 208bf9324ab994b5e59ed50d78998b4dbf8810b3b6799b1a577ef3b84479335f
This commit is contained in:
Quintine committed 2026-09-13 20:45:42 +10:00
1 parent 9381ef140d
commit 1380271bb6
22 files changed
+1166 -25

No files matched your search

+53 -3
View File
@@ -147,6 +147,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { BattlePreparation, battlePlanCommitments, bindBattlePlanCommitments, NexusInspectionHistory } from './battle-preparation';
import { PrivateBattlePlan } from './private-battle-plan';
import { SpiceBankerControl, spiceBankerControlState } from './spice-banker';
import { canUsePlanetologistBattleSpecial, validLeaderSkillBattleCardPair } from '../game/leader-skill-combat';
import type { Card } from '../game/cards';
import { RevealedBattle } from './revealed-battle';
@@ -379,6 +380,7 @@ export function GameTable({
)
: 0;
const [support, setSupport] = useState(0);
const [bankerSpice, setBankerSpice] = useState(0);
const strongholdBankSupport =
g.battle?.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0;
const battleSupportMaximum = Math.min(
@@ -413,6 +415,34 @@ export function GameTable({
const [discardCards, setDiscardCards] = useState<Record<string, boolean>>({});
const stormLosses = g.decision?.kind === 'stormLosses' ? g.decision : null;
const committed = battlePlanCommitments(g);
const selectedBattleLeader = committed.leader
? String(committed.leader.value ?? '')
: leader;
const paidBattleSupport = Math.max(0, battleSupport - strongholdBankSupport);
const selectedBattleAllyPayment =
allyPayment === ''
? Math.max(0, paidBattleSupport - (me.spice ?? 0))
: Math.max(
0,
Math.min(
Math.trunc(Number(allyPayment)),
paidBattleSupport,
g.aid.available,
),
);
const ownBattleSupport = Math.max(
0,
paidBattleSupport - selectedBattleAllyPayment,
);
const bankerControl = spiceBankerControlState(
g,
selectedBattleLeader,
ownBattleSupport,
);
const battleBankerSpice = Math.max(
0,
Math.min(Math.trunc(bankerSpice), bankerControl.maximum),
);
const planetologistSpecial = (card: Card) => canUsePlanetologistBattleSpecial({
assignments: g.leaderSkills?.assignments.filter(a => a.controller === me.id) ?? [],
selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader,
@@ -4239,6 +4269,7 @@ export function GameTable({
fill={(plan) => {
setDial(plan.dial);
setSupport(plan.support);
setBankerSpice(plan.bankerSpice ?? 0);
setAllyPayment(
plan.allyPayment === undefined
? ''
@@ -4450,6 +4481,14 @@ export function GameTable({
))}
</select>
</label>
<SpiceBankerControl
game={g}
selectedLeader={selectedBattleLeader}
ownSupport={ownBattleSupport}
value={battleBankerSpice}
onChange={setBankerSpice}
disabled={busy}
/>
{[
['Weapon', weapon, setWeapon],
['Defense', defense, setDefense],
@@ -4556,6 +4595,9 @@ export function GameTable({
: 'Seal battle plan',
bindBattlePlanCommitments(g, {
type: 'battlePlan',
...(battleBankerSpice
? { bankerSpice: battleBankerSpice }
: {}),
...(g.aid.available > 0 && allyPayment !== ''
? { allyPayment: Number(allyPayment) }
: {}),
@@ -4580,9 +4622,17 @@ export function GameTable({
)}
</>
) : (
<p className="muted">
Your plan is sealed. Waiting for your opponent.
</p>
<>
<p className="muted">
Your plan is sealed. Waiting for your opponent.
</p>
{!!g.battle.plans[me.id]?.bankerSpice && (
<p className="fine">
Spice Banker: {g.battle.plans[me.id].bankerSpice}{' '}
spice committed.
</p>
)}
</>
)}
</>
)
+1 -1
View File
@@ -113,7 +113,7 @@ export function LeaderSkillsPanel({
modifiers for Warmaster, Master of Assassins, Swordmaster of Ginaz,
Killer Medic and Prana-Bindu Adept, plus Planetologist movement and
Special-card battle use, Suk Graduate force rescue, Mentat and Bureaucrat
battle scores, Sandmaster victory spice, Rihani inspections/exchanges and optional Smuggler reserve shipping. Other Leader Skill effects are
battle scores, Sandmaster victory spice, Rihani inspections/exchanges, optional Smuggler reserve shipping and Spice Banker battle spending. Other Leader Skill effects are
unfinished.
</p>
</div>
+4
View File
@@ -45,6 +45,10 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
<dt>Spice support</dt>
<dd>{insight.plan.support}</dd>
</div>
<div>
<dt>Spice Banker commitment</dt>
<dd>{insight.plan.bankerSpice ?? 0}</dd>
</div>
<div>
<dt>Leader</dt>
<dd>{name(insight.plan.leader)}</dd>
+3
View File
@@ -51,6 +51,9 @@ export function RevealedBattle({ game }: { game: GameView }) {
{game.advanced ? ` · ${plan.support} spice support` : ''}
{!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
</span>
{!!plan.bankerSpice && (
<span>Spice Banker: {plan.bankerSpice} spice committed</span>
)}
{battle.native === id && !battle.cards.some(isStoneBurner) && (
<span>
Native Homeworld bonus: +{battle.nativeBattleStrength} to the
+99
View File
@@ -0,0 +1,99 @@
'use client';
import type { GameView } from '@/game/engine';
import { controlsLeader } from '@/game/leader-control';
import {
spiceBankerBattleMaximum,
spiceBankerModeSupported,
} from '@/game/spice-banker';
import { Input } from '@/components/ui/input';
export function spiceBankerControlState(
game: GameView,
selectedLeader: string,
ownSupport: number,
) {
if (!spiceBankerModeSupported(game)) return { eligible: false, maximum: 0 };
const me = game.players.find((player) => player.id === game.me);
if (!me) return { eligible: false, maximum: 0 };
const leader = me.leaders.find(
(candidate) =>
candidate.id === selectedLeader &&
!candidate.dead &&
controlsLeader(me, candidate) &&
(!candidate.usedAt || candidate.usedAt === game.battle?.territory),
);
const assignments =
game.leaderSkills?.assignments.filter(
(assignment) => assignment.controller === game.me,
) ?? [];
const eligible =
!!leader && spiceBankerBattleMaximum(assignments, leader.id, 3, 0) > 0;
return {
eligible,
maximum: eligible
? spiceBankerBattleMaximum(
assignments,
leader.id,
me.spice ?? 0,
ownSupport,
)
: 0,
};
}
export function SpiceBankerControl({
game,
selectedLeader,
ownSupport,
value,
onChange,
disabled = false,
}: {
game: GameView;
selectedLeader: string;
ownSupport: number;
value: number;
onChange: (amount: number) => void;
disabled?: boolean;
}) {
const state = spiceBankerControlState(game, selectedLeader, ownSupport);
if (!state.eligible) return null;
const commitment = Number.isFinite(value)
? Math.max(0, Math.min(Math.trunc(value), state.maximum))
: 0;
return (
<div className="notice" aria-label="Spice Banker battle commitment">
<label htmlFor="battle-banker-spice">
Spice Banker commitment
<Input
id="battle-banker-spice"
type="number"
min={0}
max={state.maximum}
value={commitment}
disabled={disabled || state.maximum === 0}
onChange={(event) =>
onChange(
Math.max(
0,
Math.min(Math.trunc(Number(event.target.value)), state.maximum),
),
)
}
/>
</label>
<p>
Plan funding: {ownSupport} own spice for force support + {commitment}{' '}
committed to Spice Banker = {ownSupport + commitment} own spice total.
</p>
<p className="fine">
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.
</p>
</div>
);
}
+3 -2
View File
@@ -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).
+6
View File
@@ -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.
+22
View File
@@ -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
+3 -2
View File
@@ -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.
+2 -1
View File
@@ -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) |
+83
View File
@@ -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.
+16
View File
@@ -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<typeof strongholdBattleEffects> | 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<string, number>();
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 }
+43 -4
View File
@@ -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,
});
}
}
+32 -3
View File
@@ -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) {
+5 -1
View File
@@ -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' });
+8 -7
View File
@@ -3563,7 +3563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring, Sandmaster victory spice, 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'] },
],
},
...[
+72
View File
@@ -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;
}
+2 -1
View File
@@ -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 });
+218
View File
@@ -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><dd>3<\/dd>/);
assert.equal(
renderToStaticMarkup(
createElement(PrivateBattlePlan, {
game: {
...inspected,
me: 'observer',
},
}),
),
'',
);
const revealed = {
...inspected,
advanced: false,
me: 'observer',
battle: {
attacker: 'emperor',
defender: 'atreides',
revealed: true,
plans: {
emperor: plan,
atreides: {
dial: 0,
support: 0,
leader: null,
weapon: null,
defense: null,
},
},
cards: [],
ownForces: null,
stoneBurner: {},
poisonTooth: {},
lateDefense: {},
native: null,
nativeBattleStrength: 0,
},
} as unknown as GameView;
const publicHtml = renderToStaticMarkup(
createElement(RevealedBattle, { game: revealed }),
);
assert.match(publicHtml, /Spice Banker: 3 spice committed/);
assert.equal(
(publicHtml.match(/Spice Banker:/g) ?? []).length,
1,
'zero commitments stay absent from public reveal',
);
});
void test('all four bot profiles reserve only remaining own spice and emit legal skilled plans', () => {
for (const [profile, expectedCap] of DIFFICULTIES.map(
(difficulty, index) => [difficulty, Math.min(index + 1, 3)] as const,
)) {
const game = spiceBankerGame(true);
game.players[0].spice = 4;
const view = viewGame(game, 'a');
view.players.find((player) => player.id === 'a')!.bot = profile;
const plans = botActions(view).filter(
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
);
assert.ok(plans.length, `${profile} omitted the skilled disc`);
for (const action of plans) {
const support = Number(action.support ?? 0);
assert.equal(
Number(action.bankerSpice ?? 0),
Math.min(expectedCap, Math.max(0, 4 - support)),
`${profile} did not reserve Banker spice after force support`,
);
}
assert.doesNotThrow(
() => applyAction(game, 'a', plans[0]),
`${profile} emitted an illegal Spice Banker plan`,
);
}
});
void test('bots do not emit a currently gated Banker commitment in a combined-module view', () => {
const game = spiceBankerGame();
const view = viewGame(game, 'a');
view.expansions = ['ix'];
for (const difficulty of DIFFICULTIES) {
view.players.find((player) => player.id === 'a')!.bot = difficulty;
const skilled = botActions(view).filter(
(action) => action.type === 'battlePlan' && action.leader === 'emperor-0',
);
assert.ok(skilled.length);
assert.ok(
skilled.every((action) => !action.bankerSpice),
`${difficulty} emitted a gated Banker commitment`,
);
}
});
+12
View File
@@ -0,0 +1,12 @@
import { leaderSkillBattle } from './leader-skill-battle-fixture';
/** Genuine skill setup and the shared conserved battle position, before sealing either plan. */
export function spiceBankerGame(advanced = false, captured = false) {
return leaderSkillBattle({
skill: 'spice-banker',
advanced,
captured,
unsealed: true,
dial: 0,
});
}
+282
View File
@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { Action } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { spiceBankerGame } from './spice-banker-fixture';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function giveTraitor(
game: ReturnType<typeof spiceBankerGame>,
owner: string,
identity: string,
) {
const recipient = game.players.find((player) => player.id === owner)!;
if (recipient.traitors.includes(identity)) return;
const replaced = recipient.traitors[0];
for (const player of game.players) {
const index = player.traitors.indexOf(identity);
if (index < 0) continue;
player.traitors[index] = replaced;
recipient.traitors[0] = identity;
return;
}
const index = game.traitorReserve!.indexOf(identity);
assert.ok(index >= 0);
game.traitorReserve![index] = replaced;
recipient.traitors[0] = identity;
}
async function fixture(
options: { advanced?: boolean; traitor?: boolean; spice?: number } = {},
) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Banker',
'emperor',
options.advanced ?? false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const code = created.view.code;
const tokens = [created.token!, joined.token!];
const original = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
for (const [index, seat] of original.entries())
sqlite
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
.run(index === 0 ? 'a' : 'd', code, seat.playerId);
const seats = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
const state = spiceBankerGame(options.advanced ?? false);
state.code = code;
state.version = (await store.rooms.readRoom(code)).version;
state.players[0].spice = options.spice ?? 20;
if (options.traitor) giveTraitor(state, 'a', 'guild-1');
sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(state), state.version, code);
store.writes.length = 0;
return { ...store, sqlite, code, seats, state };
}
const bankerPlan = (amount?: number): Action => ({
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'emperor-0',
...(amount === undefined ? {} : { bankerSpice: amount }),
});
const guildPlan: Action = {
type: 'battlePlan',
dial: 0,
support: 0,
leader: 'guild-1',
};
void test('SQLite restart keeps the Banker commitment owner-private and reserves it from other spending', async () => {
const f = await fixture({ spice: 3 });
try {
await f.rooms.act(
f.code,
f.seats[0],
f.state.version,
bankerPlan(3),
clock,
);
const sealed = await f.restart().readRoom(f.code);
assert.equal(sealed.battle?.plans.a.bankerSpice, 3);
assert.equal(sealed.players[0].spice, 3);
const owner = await f.restart().readSeatView(f.code, f.seats[0]);
const opponent = await f.restart().readSeatView(f.code, f.seats[1]);
assert.equal(owner.battle?.plans.a.bankerSpice, 3);
assert.deepEqual(owner.battle?.submitted, ['a']);
assert.deepEqual(opponent.battle?.plans, {});
assert.deepEqual(opponent.battle?.submitted, ['a']);
assert.equal('bankerSpice' in (opponent.battle?.plans.a ?? {}), false);
await assert.rejects(() =>
f
.restart()
.act(
f.code,
f.seats[0],
sealed.version,
{ type: 'bribe', target: 'd', amount: 1 },
clock,
),
);
assert.deepEqual(await f.restart().readRoom(f.code), sealed);
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
let revealed = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[0],
revealed.version,
{ type: 'traitorCall', call: false },
clock,
);
revealed = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
revealed.version,
{ type: 'traitorCall', call: false },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 0);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Spice Banker payment',
).length,
1,
);
} finally {
f.sqlite.close();
}
});
void test('concurrent final Traitor decisions waive one restored Banker payment exactly once', async () => {
const f = await fixture({ traitor: true });
try {
const bounty = f.state.players
.flatMap((player) => player.leaders)
.find((leader) => leader.id === 'guild-1')!.strength;
await f.rooms.act(
f.code,
f.seats[0],
f.state.version,
bankerPlan(3),
clock,
);
let state = await f.restart().readRoom(f.code);
await f.restart().act(f.code, f.seats[1], state.version, guildPlan, clock);
state = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[0],
state.version,
{ type: 'traitorCall', call: true },
clock,
);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.battle?.plans.a.bankerSpice, 3);
assert.equal(pending.players[0].spice, 20);
let arrivals = 0;
let release!: () => void;
const barrier = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await barrier;
};
f.writes.length = 0;
const finalCall: Action = { type: 'traitorCall', call: false };
const attempts = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], pending.version, finalCall, clock),
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(arrivals, 2);
assert.equal(
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
1,
);
assert.equal(
attempts.filter((attempt) => attempt.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
assert.equal(done.battle, null);
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(done.lastBattleContext?.winner, 'a');
assert.equal(done.players[0].spice, 20 + bounty);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Spice Banker payment',
).length,
1,
);
assert.match(
done.log.find(
(entry) => entry.automatic?.name === 'Spice Banker payment',
)!.text,
/waived/,
);
const writes = f.writes.length;
await assert.rejects(() =>
f.restart().act(f.code, f.seats[1], pending.version, finalCall, clock),
);
assert.equal(f.writes.length, writes);
assert.deepEqual(await f.restart().readRoom(f.code), done);
} finally {
f.sqlite.close();
}
});
void test('a restored legacy plan without bankerSpice resolves without reserving or paying it', async () => {
const f = await fixture({ spice: 4 });
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, bankerPlan(), clock);
const sealed = await f.restart().readRoom(f.code);
assert.equal('bankerSpice' in sealed.battle!.plans.a, false);
await f.restart().act(f.code, f.seats[1], sealed.version, guildPlan, clock);
let state = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[0],
state.version,
{ type: 'traitorCall', call: false },
clock,
);
state = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
state.version,
{ type: 'traitorCall', call: false },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].spice, 4);
assert.ok(
!done.log.some(
(entry) => entry.automatic?.name === 'Spice Banker payment',
),
);
} finally {
f.sqlite.close();
}
});
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { spiceBankerGame } from './spice-banker-fixture';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { validateLeaderSkills } from '../game/leader-skills';
function reject(g: Game, owner: string, action: Action) {
const before = JSON.stringify(g);
assert.throws(() => applyAction(g, owner, action));
assert.equal(JSON.stringify(g), before);
}
function conserve(g: Game) {
validateLeaderSkills(g.leaderSkills!, g.players);
for (const p of g.players) {
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
if (p.elites)
assert.equal(
p.elites.reserves +
p.elites.tanks +
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
5,
);
}
}
const plan = (g: Game, bankerSpice?: unknown): Action => ({
type: 'battlePlan',
leader: 'emperor-0',
dial: 0,
bankerSpice,
weapon: g.players[0].hand[0].id,
});
function settle(
g: Game,
opponent: Action = { type: 'battlePlan', leader: 'guild-1', dial: 5 },
calls: [boolean, boolean] = [false, false],
) {
g = applyAction(g, 'd', opponent);
g = applyAction(g, 'a', { type: 'traitorCall', call: calls[0] });
return applyAction(g, 'd', { type: 'traitorCall', call: calls[1] });
}
function traitor(g: Game, owner: number, identity: string) {
const p = g.players[owner];
if (p.traitors.includes(identity)) return;
const zone = [g.traitorReserve!, ...g.players.map((p) => p.traitors)].find(
(zone) => zone.includes(identity),
)!;
assert.ok(zone);
const old = p.traitors[0];
zone[zone.indexOf(identity)] = old;
p.traitors[0] = identity;
}
void test('Spice Banker spends one through three on win or loss and changes score without changing printed disc values', () => {
for (const amount of [1, 2, 3]) {
const before = spiceBankerGame();
const done = settle(applyAction(before, 'a', plan(before, amount)));
assert.equal(done.players[0].spice, 20 - amount);
assert.equal(done.lastBattleContext?.winner, amount === 1 ? 'd' : 'a');
assert.equal(
done.players[0].leaders[0].strength,
before.players[0].leaders[0].strength,
);
assert.ok(
done.log.some((l) => l.text.includes(`gained ${amount} battle strength`)),
);
conserve(done);
}
});
void test('Banker own spice and Advanced force support cannot spend the same funds', () => {
const g = spiceBankerGame(true);
g.players[0].spice = 5;
reject(g, 'a', { ...plan(g, 3), support: 3, dial: 3 });
const sealed = applyAction(g, 'a', { ...plan(g, 3), support: 2, dial: 2 });
assert.equal(sealed.players[0].spice, 5);
const done = settle(sealed, {
type: 'battlePlan',
leader: 'guild-1',
dial: 0,
});
assert.equal(done.players[0].spice, 0);
conserve(done);
});
void test('killed Bankers lose the bonus and still pay, while a sole successful traitor caller pays nothing', () => {
const g = spiceBankerGame();
const index = g.deck.findIndex((c) => c.kind === 'projectile');
const weapon = g.deck.splice(index, 1)[0];
g.players[1].hand.push(weapon);
const dead = settle(applyAction(g, 'a', plan(g, 3)), {
type: 'battlePlan',
leader: 'guild-1',
dial: 0,
weapon: weapon.id,
});
assert.equal(dead.players[0].leaders[0].dead, true);
assert.equal(dead.players[0].spice, 17);
assert.ok(!dead.log.some((l) => l.text.includes('gained 3 battle strength')));
conserve(dead);
for (const calls of [
[true, false],
[false, true],
[true, true],
] as [boolean, boolean][]) {
const g = spiceBankerGame();
traitor(g, 0, 'guild-1');
traitor(g, 1, 'emperor-0');
const done = settle(
applyAction(g, 'a', plan(g, 3)),
{ type: 'battlePlan', leader: 'guild-1', dial: 0 },
calls,
);
assert.equal(done.players[0].spice, calls[0] && !calls[1] ? 23 : 17);
assert.equal(done.players[0].leaders[0].dead, calls[1]);
conserve(done);
}
});
void test('sealed Banker commitments stay private and reject changed funds, quantities or leader custody', () => {
const g = spiceBankerGame();
g.players[0].spice = 3;
const sealed = applyAction(g, 'a', plan(g, 3));
assert.equal(viewGame(sealed, 'a').battle?.plans.a?.bankerSpice, 3);
assert.ok(!JSON.stringify(viewGame(sealed, 'd')).includes('bankerSpice'));
assert.deepEqual(
normalizeAutomaticGame(JSON.parse(JSON.stringify(sealed))),
sealed,
);
for (const corrupt of [
(g: Game) => {
g.discoveryEnabled = true;
},
(g: Game) => {
g.battle!.plans.a.bankerSpice = 4;
},
(g: Game) => {
g.players[0].spice = 2;
},
(g: Game) => {
g.battle!.plans.a.leader = 'emperor-1';
},
]) {
const bad = structuredClone(sealed);
corrupt(bad);
assert.throws(() => viewGame(bad, 'a'));
assert.throws(() => normalizeAutomaticGame(bad));
reject(bad, 'd', { type: 'battlePlan', leader: 'guild-1', dial: 0 });
}
for (const value of [-1, 4, 1.5, '3', true, null])
reject(g, 'a', plan(g, value));
reject(g, 'a', { ...plan(g, 1), leader: 'emperor-1' });
});
void test('captured Bankers spend captor funds, and legacy omitted commitments preserve ordinary plans', () => {
const g = spiceBankerGame(false, true);
const done = settle(
applyAction(g, 'a', { ...plan(g, 3), leader: 'guild-0' }),
{ type: 'battlePlan', leader: 'guild-1', dial: 0 },
);
assert.equal(done.players[0].spice, 17);
assert.equal(done.players[1].spice, 20);
assert.equal(done.players[1].leaders[0].capturedBy, undefined);
conserve(done);
const legacy = spiceBankerGame();
const old = applyAction(legacy, 'a', plan(legacy));
assert.equal(old.battle!.plans.a.bankerSpice, undefined);
const resumed = settle(JSON.parse(JSON.stringify(old)));
assert.equal(resumed.players[0].spice, 20);
assert.ok(
!resumed.log.some((l) => l.automatic?.name === 'Spice Banker payment'),
);
conserve(resumed);
});
void test('all four AI profiles have funded Banker battle plans using their private view', () => {
for (const difficulty of DIFFICULTIES) {
const g = spiceBankerGame();
g.players[0].spice = 2;
const view = viewGame(g, 'a');
view.players[0].bot = difficulty;
const actions = botActions(view).filter(
(a) => a.type === 'battlePlan' && Number(a.bankerSpice) > 0,
);
assert.ok(actions.length, difficulty);
for (const action of actions) {
assert.ok(Number(action.bankerSpice) <= 2);
const next = applyAction(g, 'a', action);
assert.equal(next.players[0].spice, 2);
conserve(next);
}
}
});