Files
Dune/game/discovery-battle.ts
T
Quintine f0d3d9c464 Prototype Jacurutu income, Testing Station and Shrine effects
Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
2026-09-13 14:50:09 +10:00

126 lines
3.6 KiB
TypeScript

import {
casualtyOptions,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
import { JACURUTU_SIETCH } from './discoveries';
/** GF9 Ecaz & Moritani rulebook, p. 13. */
export const JACURUTU_BATTLE_INCOME_RULE =
'If you win a battle in Jacurutu Sietch, gain one spice for each opposing undialed force sent to the Tanks.';
export type JacurutuTankLosses = Readonly<{
normal: number;
elite: number;
}>;
export type JacurutuBattleIncomeQuote =
| Readonly<{
kind: 'income';
winner: string;
opponent: string;
amount: number;
sentToTanks: JacurutuTankLosses;
dialed: readonly Casualties[];
}>
| Readonly<{
kind: 'allocationRequired';
winner: string;
opponent: string;
sentToTanks: JacurutuTankLosses;
choices: readonly Readonly<{ dialed: Casualties; amount: number }>[];
}>;
export type JacurutuBattleIncomeInput = Readonly<{
territory: string;
jacurutuRevealed: boolean;
winner: string | null;
opponent: string | null;
opponentForces: CombatForces | null;
opponentDial: number | null;
opponentSupport: number | null;
/** Actual opposing counters destroyed by this battle, after any future
* withdrawal or other non-Tanks movement has been removed from the pool. */
sentToTanks: JacurutuTankLosses | null;
}>;
export class JacurutuBattleIncomeError extends Error {
constructor(message: string) {
super(message);
this.name = 'JacurutuBattleIncomeError';
}
}
function whole(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
/**
* Quotes the automatic bank income from exact settled-battle evidence.
*
* A numeric Battle Wheel dial is not a physical counter count in Advanced
* combat. When different legal ordinary/elite dial allocations would change
* the income, the quote preserves those choices instead of inventing one.
*/
export function quoteJacurutuBattleIncome(
input: JacurutuBattleIncomeInput,
): JacurutuBattleIncomeQuote | null {
if (
input.territory !== JACURUTU_SIETCH ||
!input.jacurutuRevealed ||
input.winner === null
)
return null;
const forces = input.opponentForces;
const sent = input.sentToTanks;
if (
!input.opponent ||
input.opponent === input.winner ||
!forces ||
!validCombatForces(forces) ||
input.opponentDial === null ||
input.opponentSupport === null ||
!sent ||
!whole(sent.normal) ||
!whole(sent.elite) ||
sent.normal > forces.normal ||
sent.elite > forces.elite
)
throw new JacurutuBattleIncomeError(
'Jacurutu income needs the opposing plan and exact forces sent to the Tanks.',
);
const dialed = casualtyOptions(
forces,
input.opponentDial,
input.opponentSupport,
).filter(
(choice) => choice.normal <= sent.normal && choice.elite <= sent.elite,
);
if (!dialed.length)
throw new JacurutuBattleIncomeError(
'Jacurutu income has no physical dial allocation matching the settled Tanks losses.',
);
const choices = dialed.map((choice) => ({
dialed: { ...choice },
amount: sent.normal + sent.elite - choice.normal - choice.elite,
}));
const amounts = new Set(choices.map((choice) => choice.amount));
if (amounts.size > 1)
return {
kind: 'allocationRequired',
winner: input.winner,
opponent: input.opponent,
sentToTanks: { ...sent },
choices,
};
return {
kind: 'income',
winner: input.winner,
opponent: input.opponent,
amount: choices[0].amount,
sentToTanks: { ...sent },
dialed: choices.map((choice) => choice.dialed),
};
}