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>
);
}