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:
1 parent
9381ef140d
commit
1380271bb6
22 files changed
+1166
-25
No files matched your search
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user