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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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'] },
|
||||
],
|
||||
},
|
||||
...[
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user