Connect normal Spice Banker deferred bank income
Fresh classic banker-income profile preserves actual bank legs, physical once-phase use and original-faction atomic Mentat collection. Provisional earned custody stays explicit; public/old/combined modes unchanged. Final check: types/lint and 6487 offline cases; focused 140; guide 10; build; native18/four legal budgets; two original six-seat CLI/human390px rooms and82 saved seats25rooms. Final two reviews no findings. Local HTTP49/55 six known upstream503 failures, not green. No reset or deployed claim.
This commit is contained in:
1 parent
13c46450a9
commit
6785e622a7
18 files changed
+2767
-13
No files matched your search
+197
@@ -20,6 +20,7 @@ import { createSmugglerBattle, settleSmugglerBattle, smugglerBattleModeSupported
|
||||
import { quoteSandmasterMovement, validateSandmasterMovement, sandmasterRouteDistance, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
|
||||
import { BankerIncomeError, createBankerIncomeState, validateBankerIncomeState, quoteBankerIncome, commitBankerIncome, quoteBankerIncomeCollection, commitBankerIncomeCollection, projectBankerIncome, type BankerIncomeState, type BankerIncomeAuthority, type BankerIncomeContext } from './spice-banker-income';
|
||||
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
|
||||
import { diplomatRetreatChoices, type DiplomatRetreatSelection, type DiplomatRetreatDestination } from './diplomat-retreat';
|
||||
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
|
||||
@@ -927,7 +928,17 @@ export type Decision =
|
||||
event?: string;
|
||||
}
|
||||
| { kind: 'battleCards'; player: string; territory: string; cards: string[] };
|
||||
type SpiceBankerIncomePayment = {
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
kind: BankerIncomeAuthority['kind'];
|
||||
legs: {payer: string; amount: number; recipient: 'bank' | 'player'}[];
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type ResponseWindow = {
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
bureaucratPaymentEvent?: string;
|
||||
noFieldSkillProof?: string;
|
||||
@@ -1516,6 +1527,7 @@ export type Game = {
|
||||
};
|
||||
ixRicheseTechnologyEvent?: string;
|
||||
currentAuctionSale?: {
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
bureaucratPaymentEvent?: string;
|
||||
winner: string;
|
||||
@@ -1904,6 +1916,9 @@ export type Game = {
|
||||
/** Absent on legacy rooms whose starting cards were already dealt. */
|
||||
setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces';
|
||||
leaderSkills?: LeaderSkillsState;
|
||||
/** Fresh local classic profile; earned custody remains with its original faction. */
|
||||
spiceBankerIncomePreview?: true;
|
||||
spiceBankerIncome?: BankerIncomeState;
|
||||
bureaucratPayments?: {used:BureaucratPaymentUse[];pending?:{
|
||||
source:BureaucratPaymentSource;owner:string;leader:string;
|
||||
resume:{response:ResponseWindow|null;decision:Decision|null};
|
||||
@@ -7569,6 +7584,7 @@ function replaceMoritaniAssassinationTraitors(g: Game) {
|
||||
}
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
spiceBankerIncomeIntegrity(g);
|
||||
moritaniAssassinateIntegrity(g);
|
||||
bureaucratPaymentIntegrity(g);
|
||||
mentatQuestionIntegrity(g);
|
||||
@@ -7667,6 +7683,129 @@ function nativeLeaderSkill(g: Game, owner: string) {
|
||||
(a) => a.owner === owner && leaderSkillController(g, a) === owner,
|
||||
);
|
||||
}
|
||||
function bankerIncomeRule<T>(operation: () => T): T {
|
||||
try { return operation(); } catch (error) {
|
||||
if (error instanceof BankerIncomeError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function bankerIncomeContext(g: Game): BankerIncomeContext {
|
||||
return {turn: g.turn, phase: g.phase,
|
||||
players: g.players.map(p => ({id: p.id, leaders: p.leaders.map(l => ({
|
||||
id: l.id, dead: l.dead, ...(l.capturedBy ? {capturedBy: l.capturedBy} : {}),
|
||||
...(l.gholaBy ? {gholaBy: l.gholaBy} : {}),
|
||||
}))})),
|
||||
assignments: g.leaderSkills?.assignments ?? []};
|
||||
}
|
||||
function bankerIncomeProfile(g: Game): boolean {
|
||||
return g.players.length >= 2 && g.players.length <= 6 &&
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')) &&
|
||||
g.expansions.length === 0 && !g.nexusCards && !g.homeworlds && !g.techTokens &&
|
||||
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
|
||||
!g.greatMaker && !g.ecazTreachery && !g.semutaPreview && !g.mentatQuestionPreview &&
|
||||
!g.moritaniAssassinatePreview && !g.moritaniAssassinate && !g.advancedPreview &&
|
||||
!g.kullPreview && !g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview &&
|
||||
!g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
|
||||
}
|
||||
function bankerIncomePaymentSignature(payment: SpiceBankerIncomePayment): string {
|
||||
return JSON.stringify([payment.event, payment.turn, payment.phase, payment.kind, payment.legs]);
|
||||
}
|
||||
function spiceBankerIncomeIntegrity(g: Game): void {
|
||||
if (!g.spiceBankerIncomePreview) {
|
||||
requireRule(g.spiceBankerIncomePreview === undefined && g.spiceBankerIncome === undefined,
|
||||
'Deferred Spice Banker income requires its fresh local profile.');
|
||||
return;
|
||||
}
|
||||
requireRule(g.spiceBankerIncomePreview === true && bankerIncomeProfile(g) && !!g.leaderSkills && !!g.spiceBankerIncome,
|
||||
'The saved Spice Banker income profile lost its classic physical setup.');
|
||||
bankerIncomeRule(() => validateBankerIncomeState(g.spiceBankerIncome!, bankerIncomeContext(g)));
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const responses = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume, g.summonedWorm?.resume, g.bureaucratPayments?.pending?.resume,
|
||||
continuation && 'resume' in continuation ? continuation.resume : null]
|
||||
.flatMap(context => context?.response ? [context.response] : []);
|
||||
if (g.pendingKarama?.use.kind === 'cancel') responses.push(g.pendingKarama.use.response);
|
||||
for (const carrier of [g.currentAuctionSale, ...responses]) {
|
||||
const payment = carrier?.spiceBankerIncomePayment;
|
||||
if (!payment) {
|
||||
requireRule(!carrier || ('winner' in carrier ? carrier.free || carrier.origin !== 'normal' : carrier.kind !== 'guildIncome'),
|
||||
'The original paid transaction lost its native bank invoice.');
|
||||
continue;
|
||||
}
|
||||
requireRule(payment.signature === bankerIncomePaymentSignature(payment) && payment.turn === g.turn &&
|
||||
payment.phase === g.phase && typeof payment.event === 'string' && payment.event.length > 0 &&
|
||||
Array.isArray(payment.legs) && payment.legs.every(leg => g.players.some(p => p.id === leg.payer) &&
|
||||
Number.isSafeInteger(leg.amount) && leg.amount > 0 && ['bank', 'player'].includes(leg.recipient)) &&
|
||||
new Set(payment.legs.map(leg => leg.payer)).size === payment.legs.length,
|
||||
'The original paid bank transaction changed its event or contribution legs.');
|
||||
if (carrier && 'winner' in carrier)
|
||||
requireRule(payment.kind === 'auction' && !carrier.free && carrier.origin === 'normal' &&
|
||||
payment.legs.reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount &&
|
||||
payment.legs.every(leg => leg.payer === carrier.winner || leg.payer === getPlayer(g, carrier.winner).ally),
|
||||
'The deferred bank invoice no longer matches its original normal auction.');
|
||||
else if (carrier)
|
||||
requireRule(carrier.kind === 'guildIncome' && payment.kind === 'shipment' &&
|
||||
payment.legs.filter(leg => leg.recipient === 'player').reduce((sum, leg) => sum + leg.amount, 0) === carrier.amount,
|
||||
'The deferred bank invoice no longer matches its original Guild income.');
|
||||
}
|
||||
}
|
||||
function bankerIncomePayment(g: Game, kind: BankerIncomeAuthority['kind'],
|
||||
legs: SpiceBankerIncomePayment['legs']): SpiceBankerIncomePayment | undefined {
|
||||
if (!g.spiceBankerIncomePreview) return undefined;
|
||||
const payment = {event: crypto.randomUUID(), turn: g.turn, phase: g.phase, kind,
|
||||
legs: legs.filter(leg => leg.amount > 0), signature: ''};
|
||||
payment.signature = bankerIncomePaymentSignature(payment);
|
||||
return payment;
|
||||
}
|
||||
function awardBankerIncome(g: Game, payment: SpiceBankerIncomePayment | undefined,
|
||||
canceled = false, resolvedBattle = false): void {
|
||||
if (!payment) return;
|
||||
const bankLegs = payment.legs.filter(leg => leg.recipient === 'bank' || canceled)
|
||||
.map(({payer, amount}) => ({payer, amount}));
|
||||
if (!bankLegs.length) return;
|
||||
const assignment = g.leaderSkills?.assignments.find(a => a.skill === 'spice-banker');
|
||||
const leader = assignment && getPlayer(g, assignment.owner).leaders.find(l => l.id === assignment.leader);
|
||||
const trainer = assignment && leader && !leader.dead && !leader.capturedBy && !leader.gholaBy ? {
|
||||
assignment: {...assignment}, faceUp: !g.battle?.leaderSkillHidden?.[assignment.owner],
|
||||
captured: false, selected: resolvedBattle && g.battle?.plans[assignment.owner]?.leader === assignment.leader,
|
||||
resolved: payment.kind !== 'battle-support' || resolvedBattle, survived: true,
|
||||
} : null;
|
||||
const authority: BankerIncomeAuthority = {kind: payment.kind, event: payment.event,
|
||||
turn: payment.turn, phase: payment.phase, bankLegs, trainer};
|
||||
const context = bankerIncomeContext(g);
|
||||
const quote = bankerIncomeRule(() => quoteBankerIncome(g.spiceBankerIncome!, context, authority));
|
||||
g.spiceBankerIncome = bankerIncomeRule(() => commitBankerIncome(g.spiceBankerIncome!, context, authority, quote.receipt));
|
||||
if (quote.receipt?.grant) {
|
||||
const owner = getPlayer(g, quote.receipt.grant.owner);
|
||||
log(g, `${owner.name} gained 1 Spice Banker spice in front of the shield, unavailable until Mentat Pause.`,
|
||||
{faction: owner.faction, name: 'Spice Banker'});
|
||||
}
|
||||
}
|
||||
function collectBankerIncome(g: Game): void {
|
||||
if (!g.spiceBankerIncomePreview) return;
|
||||
const context = bankerIncomeContext(g), authority = {event: crypto.randomUUID(), turn: g.turn, phase: 8 as const};
|
||||
const quote = bankerIncomeRule(() => quoteBankerIncomeCollection(g.spiceBankerIncome!, context, authority));
|
||||
g.spiceBankerIncome = bankerIncomeRule(() => commitBankerIncomeCollection(g.spiceBankerIncome!, context, authority, quote.collection));
|
||||
for (const credit of quote.credits) {
|
||||
const owner = getPlayer(g, credit.owner);
|
||||
owner.spice += credit.amount;
|
||||
log(g, `${owner.name} collected ${credit.amount} deferred Spice Banker spice during Mentat Pause.`,
|
||||
{faction: owner.faction, name: 'Spice Banker'});
|
||||
}
|
||||
}
|
||||
function settleBankerShipmentIncome(g: Game, p: Player, cost: number, allyPayment: number): void {
|
||||
if (!g.spiceBankerIncomePreview) return;
|
||||
const guild = byFaction(g, 'guild');
|
||||
const bankOnly = !guild || g.karamaShipping?.player === p.id;
|
||||
const payment = bankerIncomePayment(g, 'shipment', [
|
||||
{payer: p.id, amount: cost - allyPayment, recipient: bankOnly || p.id === guild?.id ? 'bank' : 'player'},
|
||||
...(allyPayment ? [{payer: p.ally!, amount: allyPayment,
|
||||
recipient: bankOnly || p.ally === guild?.id ? 'bank' as const : 'player' as const}] : []),
|
||||
]);
|
||||
if (g.response?.kind === 'guildIncome') g.response.spiceBankerIncomePayment = payment;
|
||||
else awardBankerIncome(g, payment);
|
||||
}
|
||||
function nativeBureaucrat(g: Game) {
|
||||
return g.leaderSkills?.assignments.find(a => a.skill === 'bureaucrat' &&
|
||||
getPlayer(g,a.owner).leaders.some(l => l.id === a.leader && !l.dead && !l.capturedBy && !l.gholaBy) &&
|
||||
@@ -9024,6 +9163,18 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, true,
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam', basicExpansionLeaderSkillsProfile(g));
|
||||
}
|
||||
/** Fresh private all-fourteen-card classic setup; no public action enables income. */
|
||||
export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game {
|
||||
requireRule(bankerIncomeProfile(state) && !state.leaderSkills &&
|
||||
!state.spiceBankerIncomePreview && state.spiceBankerIncome === undefined,
|
||||
'Spice Banker income requires a fresh classic lobby, base Treachery and Leader Skills alone.');
|
||||
requireFreshSetup(state);
|
||||
const g = structuredClone(state);
|
||||
g.leaderSkills = createLeaderSkills(random);
|
||||
g.spiceBankerIncomePreview = true;
|
||||
g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g)));
|
||||
return initializeSetupGameForAudit(g, false, false, false, false, true);
|
||||
}
|
||||
/** Gated development setup for the independent three-card Ecaz variant. */
|
||||
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
|
||||
@@ -11257,7 +11408,15 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
|
||||
const winner = getPlayer(g, a.bidder!);
|
||||
if (!free) payWithAlly(g, winner, a.bid, a.allyPayment ?? 0);
|
||||
winner.hand.push(a.cards[a.index]);
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const paysEmperor = !!emperor && emperor.id !== winner.id;
|
||||
const incomePayment = !free && g.spiceBankerIncomePreview ? bankerIncomePayment(g, 'auction', [
|
||||
{payer: winner.id, amount: a.bid - (a.allyPayment ?? 0), recipient: paysEmperor ? 'player' : 'bank'},
|
||||
...(a.allyPayment ? [{payer: winner.ally!, amount: a.allyPayment,
|
||||
recipient: paysEmperor ? 'player' as const : 'bank' as const}] : []),
|
||||
]) : undefined;
|
||||
g.currentAuctionSale = {
|
||||
...(incomePayment ? {spiceBankerIncomePayment: incomePayment} : {}),
|
||||
winner: winner.id,
|
||||
amount: a.bid,
|
||||
free,
|
||||
@@ -11265,6 +11424,7 @@ function settleAuction(g: Game, free = false, automatic = false, emperorNexus =
|
||||
seller: null,
|
||||
...(!free ? stampAuctionBureaucrat(g,winner,a.bid,a.allyPayment ?? 0) : {}),
|
||||
};
|
||||
if (!paysEmperor) awardBankerIncome(g, incomePayment);
|
||||
log(
|
||||
g,
|
||||
`${faction(winner.faction).name} won a treachery card for ${emperorNexus ? `the Emperor Nexus card after proving ${a.bid} spice` : free ? 'a Karama' : `${a.bid} spice`}.${automatic ? ' The declared spice payment was the only available payment method.' : ''}`,
|
||||
@@ -17175,6 +17335,7 @@ function beginPhase(g: Game) {
|
||||
collect(g);
|
||||
}
|
||||
if (g.phase === 8) {
|
||||
collectBankerIncome(g);
|
||||
drawHarkonnenNexusBetrayalReplacement(g);
|
||||
replaceMoritaniAssassinationTraitors(g);
|
||||
if (g.inflation && g.inflation.updatedTurn < g.turn) {
|
||||
@@ -20283,6 +20444,13 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
: { faction: winner!.faction, name: 'Battle' },
|
||||
);
|
||||
}
|
||||
if (g.spiceBankerIncomePreview) for (const payment of quote.payments) {
|
||||
const player = getPlayer(g, payment.player);
|
||||
awardBankerIncome(g, bankerIncomePayment(g, 'battle-support', [
|
||||
{payer: player.id, amount: payment.ownPayment, recipient: 'bank'},
|
||||
...(payment.allyPayment ? [{payer: player.ally!, amount: payment.allyPayment, recipient: 'bank' as const}] : []),
|
||||
]), false, true);
|
||||
}
|
||||
if (jacurutuIncome?.kind === 'income' && jacurutuIncome.amount > 0) {
|
||||
winner!.spice += jacurutuIncome.amount;
|
||||
log(
|
||||
@@ -21691,6 +21859,10 @@ function finishRevival(
|
||||
})
|
||||
: null;
|
||||
payer.spice -= revival.cost;
|
||||
if (g.spiceBankerIncomePreview && !byFaction(g, 'tleilaxu')) awardBankerIncome(g, bankerIncomePayment(g,
|
||||
revival.emperorExtra ? 'emperor-extra-revival' : revival.kind === 'forces' ? 'force-revival' :
|
||||
revival.kind === 'kwisatz' ? 'kh-revival' : 'leader-revival',
|
||||
[{payer: payer.id, amount: revival.cost, recipient: 'bank'}]));
|
||||
if (revival.kind === 'forces') {
|
||||
const n = revival.amount!,
|
||||
elite = revival.elite ?? 0;
|
||||
@@ -22091,6 +22263,8 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
return;
|
||||
}
|
||||
if (auctionCancellation) {
|
||||
if (response.kind === 'emperorIncome')
|
||||
awardBankerIncome(g, g.currentAuctionSale?.spiceBankerIncomePayment, true);
|
||||
if (response.kind === 'harkonnenBonus')
|
||||
log(g, 'Karama prevented the Harkonnen bonus treachery card.');
|
||||
commitAuctionContinuation(g, auctionCancellation);
|
||||
@@ -22952,6 +23126,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
|
||||
}
|
||||
} else if (response.kind === 'guildIncome') {
|
||||
const payment = !canceled ? creditFactionPayment(g, response.owner, 'shipment', response.amount!-(bureaucratDiversion ?? 0)) : null;
|
||||
awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
|
||||
log(
|
||||
g,
|
||||
canceled
|
||||
@@ -23806,6 +23981,7 @@ function commitGuildTransport(
|
||||
g.response = guildPaymentResponse(g, guild.id,
|
||||
shipmentIncomeContributions(g, p, cost, allyPayment),
|
||||
allyPayment ? undefined : p.id);
|
||||
settleBankerShipmentIncome(g, p, cost, allyPayment);
|
||||
if (fromReserves) {
|
||||
p.reserves -= amount;
|
||||
if (p.elites) p.elites.reserves -= elite;
|
||||
@@ -24027,6 +24203,7 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
|
||||
});
|
||||
if (!settlement?.recipient && guild && guildPayment > 0)
|
||||
g.response = guildPaymentResponse(g, guild.id, shipmentIncomeContributions(g, p, cost, allyPayment),allyPayment ? undefined : p.id);
|
||||
settleBankerShipmentIncome(g, p, cost, allyPayment);
|
||||
g.karamaShipping = null;
|
||||
log(
|
||||
g,
|
||||
@@ -25698,6 +25875,22 @@ function normalizeCardNames(g: Game) {
|
||||
]);
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
if (state.spiceBankerIncomePreview) {
|
||||
const keysBySource: Record<string, readonly string[]> = {
|
||||
bid: ['type','amount','allyPayment'], passBid: ['type'],
|
||||
ship: ['type','territory','sector','amount','elite','allyPayment','smuggler'],
|
||||
guildShip: ['type','from','forces','eliteForces','territory','sector','amount','elite','allyPayment'],
|
||||
revive: ['type','amount','elite'], reviveLeader: ['type','leader'], reviveKwisatz: ['type'],
|
||||
emperorRevival: ['type','amount','elite'], passResponse: ['type'],
|
||||
traitorCall: ['type','call','leader'],
|
||||
};
|
||||
const allowed = action.type === 'decision' && state.decision?.kind === 'auctionPayment'
|
||||
? ['type','karama','card'] : keysBySource[action.type];
|
||||
requireRule(!allowed || Object.keys(action).every(key => allowed.includes(key)),
|
||||
'Use only the original native payment fields; Spice Banker income is automatic.');
|
||||
requireRule(!Object.keys(action).some(key => ['actor','bankerIncome','paymentSource','spiceBankerIncome','bankLegs','trainer'].includes(key)),
|
||||
'Spice Banker payment authority comes only from the original native transaction.');
|
||||
}
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
ixianNexusBetrayalIntegrity(state);
|
||||
ixianNexusReplacementIntegrity(state);
|
||||
@@ -30143,6 +30336,9 @@ export function viewGame(state: Game, id: string) {
|
||||
setupPending: setupPending(g),
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
bureaucrat: projectedBureaucrat(g, id),
|
||||
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
|
||||
spiceBankerIncome: g.spiceBankerIncomePreview
|
||||
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
|
||||
bribeOptions: projectedBribes(g, me),
|
||||
mentat: projectedMentat(g, id),
|
||||
rihani: projectedRihani(g, id),
|
||||
@@ -30705,6 +30901,7 @@ export function viewGame(state: Game, id: string) {
|
||||
response: g.pendingGuildBetrayal || g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response
|
||||
? {
|
||||
...g.response,
|
||||
...(g.response.spiceBankerIncomePayment ? {spiceBankerIncomePayment: undefined} : {}),
|
||||
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
|
||||
...(g.response.guildContributions ? { guildContributions: undefined } : {}),
|
||||
...(g.response.guildPaymentProof ? { guildPaymentProof: undefined } : {}),
|
||||
|
||||
+35
-4
@@ -4439,6 +4439,37 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Genuine setup, all physical assignments, Terror death/revival, movement/payment/battle continuations, mandatory retention exclusions and authenticated concurrent recovery have focused checks. Sample and browser results are recorded separately; full combined-rule acceptance remains open.', evidence: ['tests/moritani-skills-integration.test.ts', 'tests/moritani-skills-custody.test.ts', 'tests/moritani-skills-battle.test.ts', 'tests/moritani-skills-recovery.test.ts', 'tests/moritani-skills-worm.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spice-banker-income',
|
||||
title: 'Spice Banker: front-shield income until Mentat',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Verified',
|
||||
summary: 'The fresh banker-income preview gains one deferred spice once per phase when another player makes one qualifying payment to the Spice Bank, then collects automatically at native Mentat.',
|
||||
searchText: 'Spice Banker normal income bank payment four spice phase deferred front shield original owner death capture Mentat',
|
||||
steps: [
|
||||
'This separate fresh local profile requires unique two-to-six classic factions, Basic or Advanced, the base Treachery Deck and all fourteen Leader Skills alone. No public start, old-game retrofit, expansion deck, Nexus, Homeworld, Discovery, Tech Token or other overlay is enabled.',
|
||||
'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.',
|
||||
'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.',
|
||||
'Use the original actual bank portion after recipient and native-counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support retain their original money, card, force and phase continuation once. Canceled Emperor or Guild income can leave a real bank payment; a stopped shipment cannot.',
|
||||
'Actual payer contribution legs stay distinct. Two different players paying two each do not become one player paying four. Banker’s separate one-to-three battle payment does not combine with two support to manufacture a qualifying payment. These split-source conventions are explicit conservative local compositions, not a publisher split-payment example.',
|
||||
'A new grant requires an actual available living native trainer. A captive grants no normal income. Hidden unselected skills do not supply their normal band; in the trainer’s own battle, its actual selected surviving leader supplies the appropriate eligibility after reveal/resolution, not private pre-reveal timing.',
|
||||
'One physical Banker card has one use per turn and phase. Repeated payments, refresh, death, return or same-phase reassignment cannot reset that use; the next phase or turn permits a new grant.',
|
||||
'The front-shield pile is not spendable spice. It cannot fund the triggering payment, a later bid, shipment, revival, support or lower-band Banker commitment before collection. Human and all four AI policies keep using their actual available balance.',
|
||||
'At the real current Mentat opening, collect the uncollected grants automatically once before optional actions and victory. No extra response, counter, confirmation or collect action is added. Collection itself is not another player-to-bank payment.',
|
||||
'This fresh local prototype provisionally keeps already-earned spice with the original gaining faction when the trainer dies, is captured or the skill changes owner. That is a conservative earned-currency custody choice, not an answer to the previously asked universal death/capture question.',
|
||||
'Public projection shows only physical front-shield owner/count and current-phase use. Private payment receipts, exact hidden source amounts, plans, and future skill entitlement are not exposed. Current and collected history survive JSON/recovery without replaying either original payment or collection.',
|
||||
'Bounded development verification covers actual payment families, threshold/recipient/trainer eligibility, deferred budgets, lifecycle custody and authenticated payment/collection races. Native smoke preserves all 33 Treachery Cards and 14 skills; two original-backed-up six-seat Basic/Advanced rooms verify human phone payment, refresh and actual Mentat collection. Local HTTP remains 49/55 with six upstream 503 failures, not green. Existing lower battle spending stays separate; complete skill/module and deployment acceptance remain open.',
|
||||
],
|
||||
related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, native skill eligibility, one physical-card phase grant and original-owner automatic Mentat collection connect through the fresh classic skill profile.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'A full-width read-only public front-shield notice explains unavailable spice and automatic collection; genuine original payment and final Collection controls work at 390px through refresh. No new income action or confirmation.', evidence: ['components/spice-banker-income.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies preserve identical available-budget actions with deferred income present or absent. Original wallets exclude front-shield currency; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'game/spice-banker.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed gain/threshold/one-payment/phase/collection, original payment separation, provisional earned custody and per-payer split composition are explicit without universal rulings.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Types/lint, 6,487 offline tests and 140 focused cases pass. Eighteen native source-to-Mentat cases, four legal-budget policies, two preserved six-seat CLI/phone rooms and two independent no-finding reviews cover this bounded profile. Local HTTP 49/55 remains limited by six 503 failures; no deployed/full-module claim.', evidence: ['tests/fixture-spice-banker-income.ts', 'tests/spice-banker-income.test.ts', 'tests/spice-banker-income-runtime.test.ts', 'tests/spice-banker-income-recovery.test.ts', 'tests/prototype-room.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'leader-skills',
|
||||
title: 'Leader Skills',
|
||||
@@ -4460,17 +4491,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. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
|
||||
'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.',
|
||||
'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 connects only in the separate fresh classic banker-income profile; ordinary/default and combined starts are unchanged.',
|
||||
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
|
||||
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in the Basic Tleilaxu skill profile have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
|
||||
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
|
||||
'Other special Sandmaster relocation and Spice Banker normal income remain missing. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
'Other special Sandmaster relocation remains missing. Normal Banker income has a separate fresh classic source-to-Mentat preview with provisional original-earned-faction custody, not general activation. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. 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: ['choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', '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, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Spice Banker normal income, other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.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, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.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 explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.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/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; 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/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import { LEADER_SKILL_CARDS } from './leader-skill-cards';
|
||||
import type { LeaderSkillAssignment, SkillRoster } from './leader-skills';
|
||||
|
||||
export type BankerIncomeSourceKind =
|
||||
| 'auction'
|
||||
| 'shipment'
|
||||
| 'force-revival'
|
||||
| 'leader-revival'
|
||||
| 'kh-revival'
|
||||
| 'emperor-extra-revival'
|
||||
| 'battle-support';
|
||||
export type BankerIncomeContext = {
|
||||
turn: number;
|
||||
phase: number;
|
||||
players: SkillRoster;
|
||||
assignments: readonly LeaderSkillAssignment[];
|
||||
};
|
||||
export type BankerIncomeAuthority = {
|
||||
kind: BankerIncomeSourceKind;
|
||||
event: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
/** Only positive, actually paid final BANK legs of this one native payment. */
|
||||
bankLegs: { payer: string; amount: number }[];
|
||||
trainer: {
|
||||
assignment: LeaderSkillAssignment;
|
||||
faceUp: boolean;
|
||||
captured: boolean;
|
||||
selected: boolean;
|
||||
resolved: boolean;
|
||||
survived: boolean;
|
||||
} | null;
|
||||
};
|
||||
export type BankerIncomeGrant = { owner: string; amount: 1; stamp: string };
|
||||
export type BankerIncomeReceipt = {
|
||||
cursor: number;
|
||||
previous: string;
|
||||
source: BankerIncomeAuthority;
|
||||
grant: BankerIncomeGrant | null;
|
||||
signature: string;
|
||||
};
|
||||
export type BankerIncomeCredit = { owner: string; amount: number };
|
||||
export type BankerIncomeCollectionAuthority = { event: string; turn: number; phase: 8 };
|
||||
export type BankerIncomeCollection = BankerIncomeCollectionAuthority & {
|
||||
cursor: number;
|
||||
previous: string;
|
||||
grants: number[];
|
||||
credits: BankerIncomeCredit[];
|
||||
signature: string;
|
||||
};
|
||||
export type BankerIncomeState = {
|
||||
version: 1;
|
||||
seats: string[];
|
||||
sources: BankerIncomeReceipt[];
|
||||
collections: BankerIncomeCollection[];
|
||||
cursor: number;
|
||||
head: string;
|
||||
signature: string;
|
||||
};
|
||||
export class BankerIncomeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BankerIncomeError';
|
||||
}
|
||||
}
|
||||
const LIMIT = 50_000;
|
||||
const sourcePhases: Record<BankerIncomeSourceKind, number> = {
|
||||
auction: 3, shipment: 5, 'force-revival': 4, 'leader-revival': 4,
|
||||
'kh-revival': 4, 'emperor-extra-revival': 4, 'battle-support': 6,
|
||||
};
|
||||
const text = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0 && value.length <= 512 && value.trim() === value;
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
const array = (value: unknown): boolean => Array.isArray(value);
|
||||
function requireIncome(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new BankerIncomeError(message);
|
||||
}
|
||||
function keys(value: unknown, expected: readonly string[]): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
||||
Object.keys(value).length === expected.length && expected.every((key) => Object.hasOwn(value, key));
|
||||
}
|
||||
/** Fixed-size consistency checksum, not authentication of a rewritten save. */
|
||||
function signature(value: unknown): string {
|
||||
const serialized = JSON.stringify(value);
|
||||
let first = 2166136261;
|
||||
let second = 3339675911;
|
||||
for (let i = 0; i < serialized.length; i++) {
|
||||
const code = serialized.charCodeAt(i);
|
||||
first = Math.imul(first ^ code, 16777619);
|
||||
second = Math.imul(second ^ code, 2246822519);
|
||||
}
|
||||
return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
const validSignature = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^[0-9a-f]{16}$/.test(value);
|
||||
function sourceFields(source: BankerIncomeAuthority): unknown[] {
|
||||
const trainer = source.trainer;
|
||||
return [source.kind, source.event, source.turn, source.phase,
|
||||
source.bankLegs.map((leg) => [leg.payer, leg.amount]), trainer === null ? null : [
|
||||
trainer.assignment.skill, trainer.assignment.leader, trainer.assignment.owner,
|
||||
trainer.faceUp, trainer.captured, trainer.selected, trainer.resolved, trainer.survived,
|
||||
]];
|
||||
}
|
||||
function receiptSignature(receipt: BankerIncomeReceipt): string {
|
||||
return signature(['banker-income-source', receipt.cursor, receipt.previous,
|
||||
sourceFields(receipt.source), receipt.grant === null ? null :
|
||||
[receipt.grant.owner, receipt.grant.amount, receipt.grant.stamp]]);
|
||||
}
|
||||
function collectionSignature(collection: BankerIncomeCollection): string {
|
||||
return signature(['banker-income-collection', collection.cursor, collection.previous,
|
||||
collection.event, collection.turn, collection.phase, collection.grants,
|
||||
collection.credits.map((credit) => [credit.owner, credit.amount])]);
|
||||
}
|
||||
function stateSignature(state: BankerIncomeState): string {
|
||||
return signature(['banker-income-state', state.version, state.seats,
|
||||
state.sources.length, state.collections.length, state.cursor, state.head]);
|
||||
}
|
||||
function stamp(turn: number, phase: number): string {
|
||||
return JSON.stringify([turn, phase, 'spice-banker']);
|
||||
}
|
||||
function validateContext(context: BankerIncomeContext): string[] {
|
||||
requireIncome(keys(context, ['turn', 'phase', 'players', 'assignments']) &&
|
||||
whole(context.turn) && context.turn > 0 && whole(context.phase) && context.phase <= 8 &&
|
||||
array(context.players) && context.players.length >= 2 && context.players.length <= 6 &&
|
||||
array(context.assignments), 'Invalid Banker income native context.');
|
||||
const seats: string[] = [];
|
||||
const leaders = new Set<string>();
|
||||
for (const player of context.players) {
|
||||
requireIncome(keys(player, ['id', 'leaders']) && text(player.id) && array(player.leaders) &&
|
||||
!seats.includes(player.id), 'Invalid Banker income native roster.');
|
||||
seats.push(player.id);
|
||||
for (const leader of player.leaders) {
|
||||
requireIncome(leader && typeof leader === 'object' && !Array.isArray(leader) &&
|
||||
Object.keys(leader).every((key) => ['id', 'dead', 'capturedBy', 'gholaBy'].includes(key)) &&
|
||||
text(leader.id) && typeof leader.dead === 'boolean' && !leaders.has(leader.id) &&
|
||||
(leader.capturedBy === undefined || text(leader.capturedBy)) &&
|
||||
(leader.gholaBy === undefined || text(leader.gholaBy)), 'Invalid Banker income native leader.');
|
||||
leaders.add(leader.id);
|
||||
}
|
||||
}
|
||||
const skills = new Set<string>();
|
||||
const owners = new Set<string>();
|
||||
for (const assignment of context.assignments) {
|
||||
requireIncome(keys(assignment, ['skill', 'leader', 'owner']) &&
|
||||
LEADER_SKILL_CARDS.some((card) => card.id === assignment.skill) &&
|
||||
!skills.has(assignment.skill) && !owners.has(assignment.owner) &&
|
||||
context.players.some((player) => player.id === assignment.owner &&
|
||||
player.leaders.some((leader) => leader.id === assignment.leader)),
|
||||
'Invalid Banker income native skill assignment.');
|
||||
skills.add(assignment.skill);
|
||||
owners.add(assignment.owner);
|
||||
}
|
||||
return seats.sort();
|
||||
}
|
||||
function validateSource(context: BankerIncomeContext, source: BankerIncomeAuthority, current: boolean): void {
|
||||
requireIncome(keys(source, ['kind', 'event', 'turn', 'phase', 'bankLegs', 'trainer']) &&
|
||||
Object.hasOwn(sourcePhases, source.kind) && text(source.event) &&
|
||||
whole(source.turn) && source.turn > 0 && whole(source.phase) &&
|
||||
source.phase === sourcePhases[source.kind] &&
|
||||
(source.turn < context.turn || (source.turn === context.turn && source.phase <= context.phase)) &&
|
||||
(!current || (source.turn === context.turn && source.phase === context.phase)) &&
|
||||
array(source.bankLegs) && source.bankLegs.length <= context.players.length,
|
||||
'Invalid, unsupported or stale Banker income payment source.');
|
||||
const payers = new Set<string>();
|
||||
for (const leg of source.bankLegs) {
|
||||
requireIncome(keys(leg, ['payer', 'amount']) && text(leg.payer) &&
|
||||
context.players.some((player) => player.id === leg.payer) && !payers.has(leg.payer) &&
|
||||
whole(leg.amount) && leg.amount > 0, 'Banker income requires distinct actual positive BANK payer legs.');
|
||||
payers.add(leg.payer);
|
||||
}
|
||||
const trainer = source.trainer;
|
||||
if (trainer === null) return;
|
||||
requireIncome(keys(trainer, ['assignment', 'faceUp', 'captured', 'selected', 'resolved', 'survived']) &&
|
||||
keys(trainer.assignment, ['skill', 'leader', 'owner']) &&
|
||||
trainer.assignment.skill === 'spice-banker' && text(trainer.assignment.leader) &&
|
||||
text(trainer.assignment.owner) &&
|
||||
[trainer.faceUp, trainer.captured, trainer.selected, trainer.resolved, trainer.survived]
|
||||
.every((value) => typeof value === 'boolean') &&
|
||||
(!trainer.selected || (!trainer.faceUp && source.kind === 'battle-support')) &&
|
||||
(source.kind === 'battle-support' || (!trainer.selected && trainer.resolved && trainer.survived)),
|
||||
'Invalid Banker income trainer posture or resolved survival.');
|
||||
const native = context.players.find((player) => player.id === trainer.assignment.owner)
|
||||
?.leaders.find((leader) => leader.id === trainer.assignment.leader);
|
||||
requireIncome(native, 'Banker income trainer is not its original native disc.');
|
||||
if (current) {
|
||||
requireIncome(!native.dead && !!native.capturedBy === trainer.captured &&
|
||||
context.assignments.some((assignment) => assignment.skill === 'spice-banker' &&
|
||||
assignment.owner === trainer.assignment.owner && assignment.leader === trainer.assignment.leader),
|
||||
'Banker income source lacks the actual living native trainer and physical skill.');
|
||||
}
|
||||
}
|
||||
export function validateBankerIncomeSource(context: BankerIncomeContext, source: BankerIncomeAuthority): void {
|
||||
validateContext(context);
|
||||
validateSource(context, source, true);
|
||||
}
|
||||
function expectedGrant(source: BankerIncomeAuthority, used: ReadonlySet<string>): BankerIncomeGrant | null {
|
||||
const trainer = source.trainer;
|
||||
const phaseStamp = stamp(source.turn, source.phase);
|
||||
if (trainer === null || trainer.captured || !trainer.survived ||
|
||||
(source.kind === 'battle-support' && !trainer.resolved) ||
|
||||
(!trainer.faceUp && !(trainer.selected && trainer.resolved)) || used.has(phaseStamp) ||
|
||||
!source.bankLegs.some((leg) => leg.payer !== trainer.assignment.owner && leg.amount >= 4)) return null;
|
||||
return { owner: trainer.assignment.owner, amount: 1, stamp: phaseStamp };
|
||||
}
|
||||
function creditsFor(seats: readonly string[], pending: readonly BankerIncomeReceipt[]): BankerIncomeCredit[] {
|
||||
return seats.flatMap((owner) => {
|
||||
const amount = pending.reduce((total, receipt) => total + (receipt.grant?.owner === owner ? 1 : 0), 0);
|
||||
return amount === 0 ? [] : [{ owner, amount }];
|
||||
});
|
||||
}
|
||||
function sameCredits(left: readonly BankerIncomeCredit[], right: readonly BankerIncomeCredit[]): boolean {
|
||||
return left.length === right.length && left.every((credit, index) =>
|
||||
keys(credit, ['owner', 'amount']) && credit.owner === right[index].owner && credit.amount === right[index].amount);
|
||||
}
|
||||
/** Closed history checks original entitlement, never current life, card custody or wallet. */
|
||||
export function validateBankerIncomeState(state: BankerIncomeState, context: BankerIncomeContext): void {
|
||||
const seats = validateContext(context);
|
||||
requireIncome(keys(state, ['version', 'seats', 'sources', 'collections', 'cursor', 'head', 'signature']) &&
|
||||
state.version === 1 && array(state.seats) &&
|
||||
state.seats.length === seats.length && state.seats.every((seat, index) => seat === seats[index]) &&
|
||||
array(state.sources) && array(state.collections) && whole(state.cursor) &&
|
||||
state.cursor === state.sources.length + state.collections.length && state.cursor <= LIMIT &&
|
||||
validSignature(state.head) && validSignature(state.signature), 'Malformed Banker income history.');
|
||||
let head = signature(['banker-income-genesis', seats]);
|
||||
let sourceIndex = 0;
|
||||
let collectionIndex = 0;
|
||||
let lastTurn = 0;
|
||||
let lastPhase = 0;
|
||||
const events = new Set<string>();
|
||||
const used = new Set<string>();
|
||||
const collectedTurns = new Set<number>();
|
||||
let pending: BankerIncomeReceipt[] = [];
|
||||
for (let cursor = 1; cursor <= state.cursor; cursor++) {
|
||||
const receipt = state.sources[sourceIndex];
|
||||
const collection = state.collections[collectionIndex];
|
||||
const isSource = receipt?.cursor === cursor;
|
||||
requireIncome(isSource !== (collection?.cursor === cursor), 'Missing or duplicate Banker income history cursor.');
|
||||
let event: string;
|
||||
let turn: number;
|
||||
let phase: number;
|
||||
if (isSource) {
|
||||
requireIncome(keys(receipt, ['cursor', 'previous', 'source', 'grant', 'signature']) &&
|
||||
receipt.previous === head && validSignature(receipt.signature), 'Corrupt Banker income source head.');
|
||||
validateSource(context, receipt.source, false);
|
||||
const grant = expectedGrant(receipt.source, used);
|
||||
requireIncome(grant === null ? receipt.grant === null :
|
||||
keys(receipt.grant, ['owner', 'amount', 'stamp']) && receipt.grant.owner === grant.owner &&
|
||||
receipt.grant.amount === 1 && receipt.grant.stamp === grant.stamp, 'Corrupt Banker income deferred grant.');
|
||||
requireIncome(receipt.signature === receiptSignature(receipt), 'Corrupt Banker income source signature.');
|
||||
if (grant !== null) {
|
||||
used.add(grant.stamp);
|
||||
pending.push(receipt);
|
||||
}
|
||||
({ event, turn, phase } = receipt.source);
|
||||
head = receipt.signature;
|
||||
sourceIndex++;
|
||||
} else {
|
||||
requireIncome(keys(collection, ['cursor', 'previous', 'event', 'turn', 'phase', 'grants', 'credits', 'signature']) &&
|
||||
collection.previous === head && text(collection.event) && whole(collection.turn) && collection.turn > 0 &&
|
||||
collection.phase === 8 && !collectedTurns.has(collection.turn) &&
|
||||
(collection.turn < context.turn || (collection.turn === context.turn && context.phase === 8)) &&
|
||||
Array.isArray(collection.grants) && collection.grants.length === pending.length &&
|
||||
collection.grants.every((grant, index) => grant === pending[index].cursor) &&
|
||||
Array.isArray(collection.credits) && sameCredits(collection.credits, creditsFor(seats, pending)) &&
|
||||
validSignature(collection.signature) && collection.signature === collectionSignature(collection),
|
||||
'Corrupt, incomplete or replayed Banker income collection.');
|
||||
({ event, turn, phase } = collection);
|
||||
collectedTurns.add(turn);
|
||||
pending = [];
|
||||
head = collection.signature;
|
||||
collectionIndex++;
|
||||
}
|
||||
requireIncome(!events.has(event) && (turn > lastTurn || (turn === lastTurn && phase >= lastPhase)),
|
||||
'Duplicate or out-of-order Banker income native event.');
|
||||
events.add(event);
|
||||
lastTurn = turn;
|
||||
lastPhase = phase;
|
||||
}
|
||||
requireIncome(head === state.head && sourceIndex === state.sources.length &&
|
||||
collectionIndex === state.collections.length && state.signature === stateSignature(state),
|
||||
'Corrupt Banker income current head or signature.');
|
||||
}
|
||||
export function createBankerIncomeState(context: BankerIncomeContext): BankerIncomeState {
|
||||
const seats = validateContext(context);
|
||||
const state: BankerIncomeState = {
|
||||
version: 1, seats, sources: [], collections: [], cursor: 0,
|
||||
head: signature(['banker-income-genesis', seats]), signature: '',
|
||||
};
|
||||
state.signature = stateSignature(state);
|
||||
return state;
|
||||
}
|
||||
function requireFreshEvent(state: BankerIncomeState, event: string): void {
|
||||
requireIncome(state.cursor < LIMIT && !state.sources.some((receipt) => receipt.source.event === event) &&
|
||||
!state.collections.some((collection) => collection.event === event),
|
||||
'Banker income native event was already committed or history is full.');
|
||||
}
|
||||
export function quoteBankerIncome(state: BankerIncomeState, context: BankerIncomeContext, authority: BankerIncomeAuthority): {
|
||||
next: BankerIncomeState; receipt: BankerIncomeReceipt;
|
||||
} {
|
||||
validateBankerIncomeState(state, context);
|
||||
validateSource(context, authority, true);
|
||||
requireFreshEvent(state, authority.event);
|
||||
const used = new Set(state.sources.flatMap((receipt) => receipt.grant === null ? [] : [receipt.grant.stamp]));
|
||||
const receipt: BankerIncomeReceipt = {
|
||||
cursor: state.cursor + 1, previous: state.head, source: structuredClone(authority),
|
||||
grant: expectedGrant(authority, used), signature: '',
|
||||
};
|
||||
receipt.signature = receiptSignature(receipt);
|
||||
const next = structuredClone(state);
|
||||
next.sources.push(receipt);
|
||||
next.cursor = receipt.cursor;
|
||||
next.head = receipt.signature;
|
||||
next.signature = stateSignature(next);
|
||||
return { next, receipt: structuredClone(receipt) };
|
||||
}
|
||||
/** Caller commits this ledger and original native payment/suffix in the same room write. */
|
||||
export function commitBankerIncome(state: BankerIncomeState, context: BankerIncomeContext,
|
||||
authority: BankerIncomeAuthority, receipt: BankerIncomeReceipt): BankerIncomeState {
|
||||
const quote = quoteBankerIncome(state, context, authority);
|
||||
requireIncome(keys(receipt, ['cursor', 'previous', 'source', 'grant', 'signature']), 'Malformed Banker income receipt.');
|
||||
validateSource(context, receipt.source, true);
|
||||
requireIncome(receipt.cursor === quote.receipt.cursor && receipt.previous === quote.receipt.previous &&
|
||||
(quote.receipt.grant === null ? receipt.grant === null : keys(receipt.grant, ['owner', 'amount', 'stamp']) &&
|
||||
receipt.grant.owner === quote.receipt.grant.owner && receipt.grant.amount === 1 &&
|
||||
receipt.grant.stamp === quote.receipt.grant.stamp) &&
|
||||
receipt.signature === quote.receipt.signature && receipt.signature === receiptSignature(receipt),
|
||||
'Banker income receipt is foreign, stale or disagrees with independent native authority.');
|
||||
return quote.next;
|
||||
}
|
||||
export function quoteBankerIncomeCollection(state: BankerIncomeState, context: BankerIncomeContext,
|
||||
authority: BankerIncomeCollectionAuthority): {
|
||||
next: BankerIncomeState; collection: BankerIncomeCollection; credits: BankerIncomeCredit[];
|
||||
} {
|
||||
validateBankerIncomeState(state, context);
|
||||
requireIncome(keys(authority, ['event', 'turn', 'phase']) && text(authority.event) &&
|
||||
authority.turn === context.turn && authority.phase === 8 && context.phase === 8,
|
||||
'Banker income collects only at the actual current native Mentat opening.');
|
||||
requireFreshEvent(state, authority.event);
|
||||
requireIncome(!state.collections.some((collection) => collection.turn === authority.turn),
|
||||
'Banker income already collected at this turn\'s Mentat opening.');
|
||||
const collected = new Set(state.collections.flatMap((collection) => collection.grants));
|
||||
const pending = state.sources.filter((receipt) => receipt.grant !== null && !collected.has(receipt.cursor));
|
||||
const credits = creditsFor(state.seats, pending);
|
||||
const collection: BankerIncomeCollection = {
|
||||
...authority, cursor: state.cursor + 1, previous: state.head,
|
||||
grants: pending.map((receipt) => receipt.cursor), credits, signature: '',
|
||||
};
|
||||
collection.signature = collectionSignature(collection);
|
||||
const next = structuredClone(state);
|
||||
next.collections.push(collection);
|
||||
next.cursor = collection.cursor;
|
||||
next.head = collection.signature;
|
||||
next.signature = stateSignature(next);
|
||||
return { next, collection: structuredClone(collection), credits: structuredClone(credits) };
|
||||
}
|
||||
export function commitBankerIncomeCollection(state: BankerIncomeState, context: BankerIncomeContext,
|
||||
authority: BankerIncomeCollectionAuthority, collection: BankerIncomeCollection): BankerIncomeState {
|
||||
const quote = quoteBankerIncomeCollection(state, context, authority);
|
||||
requireIncome(keys(collection, ['cursor', 'previous', 'event', 'turn', 'phase', 'grants', 'credits', 'signature']) &&
|
||||
collection.cursor === quote.collection.cursor && collection.previous === quote.collection.previous &&
|
||||
collection.event === authority.event && collection.turn === authority.turn && collection.phase === 8 &&
|
||||
Array.isArray(collection.grants) && collection.grants.length === quote.collection.grants.length &&
|
||||
collection.grants.every((grant, index) => grant === quote.collection.grants[index]) &&
|
||||
Array.isArray(collection.credits) && sameCredits(collection.credits, quote.credits) &&
|
||||
collection.signature === quote.collection.signature && collection.signature === collectionSignature(collection),
|
||||
'Banker income collection is foreign, stale, incomplete or corrupt.');
|
||||
return quote.next;
|
||||
}
|
||||
export function projectBankerIncome(state: BankerIncomeState, context: BankerIncomeContext): {
|
||||
deferred: BankerIncomeCredit[]; usedThisPhase: string[];
|
||||
} {
|
||||
validateBankerIncomeState(state, context);
|
||||
const collected = new Set(state.collections.flatMap((collection) => collection.grants));
|
||||
const pending = state.sources.filter((receipt) => receipt.grant !== null && !collected.has(receipt.cursor));
|
||||
return {
|
||||
deferred: creditsFor(state.seats, pending),
|
||||
usedThisPhase: state.sources.flatMap((receipt) =>
|
||||
receipt.grant?.stamp === stamp(context.turn, context.phase) ? [receipt.grant.owner] : []),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user