Resolve ambiguous Stone battles with sealed physical totals
Add a provisional post-reveal physical-count commitment for new outcome-changing Basic/Advanced Stone and copied Mirror battles. Keep all original sides sealed until complete and retain matching later typed losses. Preserve legacy, ordinary and invariant-winner timing. Carry the same constraint through card cleanup and native casualty consumers. Validation: frozen 16 files146/146, types/lint/build and14 original-action/rendered-control programmes; four minimal policies make both owned choices legally. Source timing is explicitly provisional. Wider combinations, assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
1 parent
677ad7aa3c
commit
6ccfaa1b4d
22 files changed
+1247
-83
No files matched your search
@@ -76,6 +76,7 @@ const decisions = {
|
||||
techToken: true,
|
||||
poisonTooth: true,
|
||||
stoneBurner: true,
|
||||
stoneAllocation: true,
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
fullPlanOffer: true,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { casualtyOptions, type CombatForces } from './combat';
|
||||
|
||||
/** Printed physical commitments survive later card disposal and native aftercare. */
|
||||
export type BattleCasualtyConstraint = {
|
||||
fullyCommitted?: true;
|
||||
physicalLossCount?: number;
|
||||
};
|
||||
|
||||
export function casualtyConstraintFields(constraint: BattleCasualtyConstraint) {
|
||||
return {
|
||||
...(constraint.fullyCommitted ? {fullyCommitted:true as const} : {}),
|
||||
...(constraint.physicalLossCount !== undefined ? {physicalLossCount:constraint.physicalLossCount} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function committedCasualtyOptions(forces: CombatForces, dial: number, support: number,
|
||||
constraint: BattleCasualtyConstraint = {}) {
|
||||
const {fullyCommitted, physicalLossCount} = constraint;
|
||||
if ((fullyCommitted !== undefined && fullyCommitted !== true) ||
|
||||
(physicalLossCount !== undefined && (!Number.isSafeInteger(physicalLossCount) ||
|
||||
physicalLossCount < 0 || physicalLossCount > forces.normal + forces.elite))) return [];
|
||||
return casualtyOptions(forces, dial, support).filter(option =>
|
||||
(!fullyCommitted || option.normal === forces.normal && option.elite === forces.elite) &&
|
||||
(physicalLossCount === undefined || option.normal + option.elite === physicalLossCount));
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
stoneBurnerComparison,
|
||||
stoneBurnerWithdrawalContext,
|
||||
stoneBurnerUndialedTotal,
|
||||
type StoneBurnerContext,
|
||||
type StoneBurnerComparison,
|
||||
} from './stone-burner';
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
} from './leader-skill-combat';
|
||||
import { quoteEcazOccupyOutcome, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
|
||||
import { ecazSpecialKaramaBonus, ecazSpecialKaramaPlanAllowed } from './ecaz-special-karama';
|
||||
import type { StoneBurnerAllocationChoice, StoneBurnerAllocationLoss } from './stone-burner-allocation';
|
||||
|
||||
export class BattleResolutionQuoteError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -63,6 +65,20 @@ export class BattleResolutionQuoteError extends Error {
|
||||
this.name = 'BattleResolutionQuoteError';
|
||||
}
|
||||
}
|
||||
/** Pure prerequisite only: no combat resources have been applied. The engine
|
||||
* owns the private simultaneous commitment protocol and its battle stamp. */
|
||||
export class StoneBurnerAllocationRequiredError extends BattleResolutionQuoteError {
|
||||
readonly choices: readonly StoneBurnerAllocationChoice[];
|
||||
readonly losses: Readonly<Record<string, readonly StoneBurnerAllocationLoss[]>>;
|
||||
constructor(choices: readonly StoneBurnerAllocationChoice[],
|
||||
losses: Readonly<Record<string, readonly StoneBurnerAllocationLoss[]>> = {}) {
|
||||
super('Stone Burner requires private undialed physical totals before determining this battle winner.');
|
||||
this.name = 'StoneBurnerAllocationRequiredError';
|
||||
this.choices = choices.map(choice => ({ ...choice, totals: [...choice.totals] }));
|
||||
this.losses = Object.fromEntries(Object.entries(losses).map(([player, options]) => [player,
|
||||
options.map(option => ({ undialed: option.undialed, losses: { ...option.losses } }))]));
|
||||
}
|
||||
}
|
||||
export type ResolutionPlan = {
|
||||
bankerSpice?: number;
|
||||
planetologistSpecial?: true;
|
||||
@@ -113,6 +129,9 @@ export type ResolutionCombatant = ResolutionParticipant & {
|
||||
ecazSpecialKarama?: true;
|
||||
};
|
||||
export type BattleResolutionInput = {
|
||||
/** New battle protocol: physical undialed totals, keyed by the stable side
|
||||
* player, without preselecting the winner's later typed casualty tuple. */
|
||||
stoneAllocations?: Readonly<Record<string, number>>;
|
||||
ecazOccupy?: EcazOccupyBattleProfile;
|
||||
/** Stable physical slots remain unchanged when Sapho changes aggressor. */
|
||||
aggressor?: string;
|
||||
@@ -187,6 +206,9 @@ export type BattleResolutionQuote = {
|
||||
sandmaster?: string;
|
||||
casualties: {
|
||||
owner?: string;
|
||||
/** A selected Stone total constrains the later variable physical losses,
|
||||
* after any fixed Ecaz contribution and actual withdrawal. */
|
||||
physicalLossCount?: number;
|
||||
/** The returned Harass complement fixes all remaining physical fighters. */
|
||||
fullyCommitted?: true;
|
||||
forces: CombatForces;
|
||||
@@ -212,6 +234,25 @@ export type BattleResolutionQuote = {
|
||||
function requireQuote(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new BattleResolutionQuoteError(message);
|
||||
}
|
||||
/** Shared pure validation accepts partial commitments while gathering choices.
|
||||
* The resolution itself requires every originally ambiguous side to commit. */
|
||||
export function validateStoneBurnerAllocationSelections(
|
||||
choices: readonly StoneBurnerAllocationChoice[],
|
||||
selections: Readonly<Record<string, number>> | undefined,
|
||||
complete = false,
|
||||
): Readonly<Record<string, number>> {
|
||||
requireQuote(selections === undefined || (!!selections && typeof selections === 'object' &&
|
||||
!Array.isArray(selections) && [Object.prototype, null].includes(Object.getPrototypeOf(selections))),
|
||||
'Stone Burner allocations must identify original side players and legal physical totals.');
|
||||
const selected = selections ?? {};
|
||||
requireQuote(Object.entries(selected).every(([player, total]) => {
|
||||
const choice = choices.find(choice => choice.player === player);
|
||||
return choice && Number.isSafeInteger(total) && choice.totals.includes(total);
|
||||
}), 'Stone Burner allocations must identify original side players and legal physical totals.');
|
||||
if (complete && !choices.every(choice => Object.hasOwn(selected, choice.player)))
|
||||
throw new StoneBurnerAllocationRequiredError(choices);
|
||||
return selected;
|
||||
}
|
||||
const id = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
const whole = (value: number) => Number.isSafeInteger(value) && value >= 0;
|
||||
@@ -525,7 +566,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
stoneContext = stoneBurnerWithdrawalContext(
|
||||
stoneContext, side === a ? 'attacker' : 'defender', side.harassWithdraw?.occupy,
|
||||
);
|
||||
const stone = hasStone
|
||||
let stone = hasStone
|
||||
? stoneBurnerComparison(
|
||||
a.forces,
|
||||
a.plan.dial,
|
||||
@@ -537,10 +578,37 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
stoneContext,
|
||||
)
|
||||
: null;
|
||||
const explosion = battleWeaponsExplode(aw, ad, dw, dd);
|
||||
const allocationChoices: StoneBurnerAllocationChoice[] = stone && !stone.winner && !ac && !dc && !explosion
|
||||
? ([['attacker', a], ['defender', d]] as const).flatMap(([slot, side]) => stone![slot].length > 1 ? [{
|
||||
player: side.id,
|
||||
owner: input.ecazOccupy && !input.ecazOccupy.canceled && input.ecazOccupy.lead === side.id
|
||||
? input.ecazOccupy.forceOwner : side.id,
|
||||
totals: [...stone![slot]],
|
||||
}] : []) : [];
|
||||
const stoneAllocationLosses = Object.fromEntries(allocationChoices.map(choice => {
|
||||
const side = choice.player === a.id ? a : d, context = stoneContext?.[side === a ? 'attacker' : 'defender'];
|
||||
const withdrawal = harassWithdraw.find(withdrawal => withdrawal.player === choice.owner);
|
||||
const options = casualtyOptions(side.forces, side.plan.dial - (context?.fixedEcazDial ?? 0), side.plan.support)
|
||||
.filter(loss => !withdrawal || loss.normal === withdrawal.remaining.normal && loss.elite === withdrawal.remaining.elite)
|
||||
.map(losses => ({ undialed: stoneBurnerUndialedTotal(side.forces, losses, context), losses }));
|
||||
return [choice.player, options];
|
||||
}));
|
||||
const stoneAllocations = validateStoneBurnerAllocationSelections(allocationChoices, input.stoneAllocations);
|
||||
if (!allocationChoices.every(choice => Object.hasOwn(stoneAllocations, choice.player)))
|
||||
throw new StoneBurnerAllocationRequiredError(allocationChoices, stoneAllocationLosses);
|
||||
if (stone && allocationChoices.length) {
|
||||
const attacker = Object.hasOwn(stoneAllocations, a.id) ? [stoneAllocations[a.id]] : stone.attacker;
|
||||
const defender = Object.hasOwn(stoneAllocations, d.id) ? [stoneAllocations[d.id]] : stone.defender;
|
||||
requireQuote(attacker.length === 1 && defender.length === 1,
|
||||
'Complete both original Stone Burner physical allocations before resolving battle.');
|
||||
stone = { attacker, defender, winner: attacker[0] > defender[0] ||
|
||||
attacker[0] === defender[0] && tie === 'attacker' ? 'attacker' : 'defender' };
|
||||
}
|
||||
if (stone) {
|
||||
requireQuote(
|
||||
stone.attacker.length > 0 && stone.defender.length > 0 &&
|
||||
(!!stone.winner || ac || dc),
|
||||
(!!stone.winner || ac || dc || explosion),
|
||||
'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
|
||||
);
|
||||
for (const [side, weapon] of [
|
||||
@@ -614,7 +682,6 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const explosion = battleWeaponsExplode(aw, ad, dw, dd);
|
||||
const result =
|
||||
ac && dc
|
||||
? 'mutualTraitors'
|
||||
@@ -822,6 +889,15 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (winner && result === 'normal' && casualties && Object.hasOwn(stoneAllocations, winner.id)) {
|
||||
const options = casualties.options.filter(option => stoneBurnerUndialedTotal(winner.forces, option,
|
||||
stoneContext?.[winner === a ? 'attacker' : 'defender']) === stoneAllocations[winner.id]);
|
||||
requireQuote(options.length > 0, 'The Stone Burner winner must retain a typed casualty allocation matching its committed physical total.');
|
||||
const physicalLossCount = options[0].normal + options[0].elite;
|
||||
requireQuote(options.every(option => option.normal + option.elite === physicalLossCount),
|
||||
'The selected Stone Burner total must retain one variable physical loss count.');
|
||||
casualties = { ...casualties, options, physicalLossCount };
|
||||
}
|
||||
const strongholdIncome =
|
||||
result === 'mutualTraitors'
|
||||
? []
|
||||
|
||||
@@ -1008,6 +1008,7 @@ function plans(g: GameView): Action[] {
|
||||
b.attacker === me.id ? 'attacker' : 'defender',
|
||||
b.tieWinner === b.attacker ? 'attacker' : 'defender',
|
||||
stoneContext,
|
||||
b.stoneBurnerContext?.allowAllocations,
|
||||
),
|
||||
))
|
||||
)
|
||||
@@ -2429,6 +2430,12 @@ function policyActions(g: GameView): Action[] {
|
||||
)[0];
|
||||
return [{ type: 'decision', token }];
|
||||
}
|
||||
if (d.kind === 'stoneAllocation') {
|
||||
const offer = g.battle?.stoneAllocation;
|
||||
const undialed = offer?.totals.at(-1);
|
||||
return offer && offer.owner === me.id && offer.event === d.event && undialed !== undefined
|
||||
? [{type:'decision',event:d.event,undialed}] : [];
|
||||
}
|
||||
if (d.kind === 'stoneBurner') {
|
||||
const b = g.battle!,
|
||||
own = b.plans[me.id];
|
||||
|
||||
+195
-63
@@ -334,6 +334,7 @@ import {
|
||||
BattleResolutionQuoteError,
|
||||
type ResolutionCombatant,
|
||||
type BattleResolutionQuote,
|
||||
type BattleResolutionInput,
|
||||
} from './battle-resolution-quote';
|
||||
import {
|
||||
quoteRevivalCancellation,
|
||||
@@ -381,6 +382,8 @@ import {
|
||||
sampleAuditCards,
|
||||
} from './choam-auditor';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext, type StoneBurnerContext } from './stone-burner';
|
||||
import { quoteStoneBurnerAllocations, stoneBurnerCommittedLosses, type StoneBurnerAllocationChoice } from './stone-burner-allocation';
|
||||
import { casualtyConstraintFields, committedCasualtyOptions } from './battle-casualty-commitment';
|
||||
import {
|
||||
STRONGHOLD_CARDS,
|
||||
createStrongholdCards,
|
||||
@@ -768,6 +771,14 @@ export type Battle = {
|
||||
truthPromises?: BattlePromise[];
|
||||
poisonTooth?: Record<string, boolean>;
|
||||
stoneBurner?: Record<string, 'kill' | 'ignore'>;
|
||||
/** New ambiguous Stone battles commit physical totals without changing ordinary casualty timing. */
|
||||
stoneAllocationVersion?: 1;
|
||||
stoneAllocation?: {
|
||||
event: string; battle: string; turn: number; context: string;
|
||||
choices: readonly StoneBurnerAllocationChoice[];
|
||||
selections: Record<string, number>;
|
||||
complete?: true;
|
||||
};
|
||||
/** Supplemental revealed defense; never rewrites the original sealed plan. */
|
||||
lateDefense?: Record<string, string>;
|
||||
fullPlan?: { owner: string; target: string };
|
||||
@@ -916,6 +927,7 @@ export type Decision =
|
||||
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
|
||||
| { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null }
|
||||
| { kind: 'stoneAllocation'; player: string; event: string }
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[]; reserveHomes?: { id: string; name: string; secondary: boolean }[] }
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
@@ -1565,6 +1577,7 @@ export type Game = {
|
||||
owner?: string;
|
||||
/** Harass has already returned the complement of this physical pool. */
|
||||
fullyCommitted?: true;
|
||||
physicalLossCount?: number;
|
||||
forces: CombatForces;
|
||||
dial: number;
|
||||
support: number;
|
||||
@@ -2004,7 +2017,7 @@ export type Game = {
|
||||
nexusAdvisorLast?: { event: string; stage: 'pending' | 'completed' | 'canceled' };
|
||||
nexusSardaukarHistory?: {receipt: NexusSardaukarReceipt; stage: 'pending' | 'active' | 'canceled'; parent: string; signature: string;
|
||||
casualties?: {forces: CombatForces; dial: number; support: number; options: Casualties[]; outcome: 'pending' | 'complete';
|
||||
reserveFee?: {normal: number; elite: number}}}[];
|
||||
reserveFee?: {normal: number; elite: number}; fullyCommitted?: true; physicalLossCount?: number}}[];
|
||||
nexusSardaukarLast?: {event: string; stage: 'pending' | 'active' | 'canceled'};
|
||||
nexusChoamHistory?: {receipt: NexusChoamReceipt; stage: 'pending' | 'canceled' | 'complete' | 'fizzled'; frame: string; parent: string; signature: string}[];
|
||||
nexusMoritaniHistory?: {receipt:NexusMoritaniReceipt;stage:'pending'|'complete'|'canceled';before:string;frame:string;signature:string}[];
|
||||
@@ -2214,7 +2227,7 @@ export type Game = {
|
||||
options: HomeworldForces[];
|
||||
/** Original printed face; reserve-card fees cannot reprice this explosion. */
|
||||
faceAtResolution?: 'high' | 'low';
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true };
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true; physicalLossCount?: number };
|
||||
} | null;
|
||||
lastBattleContext?: {
|
||||
event: string;
|
||||
@@ -3817,11 +3830,14 @@ function nexusSardaukarIntegrity(g: Game) {
|
||||
JSON.stringify(reinforcementsBattleForces(original,fee)) === JSON.stringify(losses.forces),
|
||||
'The reduced temporary Sardaukar need their original own-World reserve fee.');
|
||||
}
|
||||
const constraints = casualtyConstraintFields(losses);
|
||||
const commitment = {forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options,...constraints};
|
||||
requireRule(record.stage === 'active' && ['pending','complete'].includes(losses.outcome) &&
|
||||
Object.keys(losses).sort().join(',') === (fee ? 'dial,forces,options,outcome,reserveFee,support' : 'dial,forces,options,outcome,support') &&
|
||||
Object.keys(losses).sort().join(',') ===
|
||||
['dial','forces','options','outcome','support',...(fee ? ['reserveFee'] : []),...Object.keys(constraints)].sort().join(',') &&
|
||||
validCombatForces(losses.forces) && (fee ? Number(losses.forces.temporaryElite) >= 2 : losses.forces.temporaryElite === 5) &&
|
||||
Array.isArray(losses.options) && losses.options.length > 0 &&
|
||||
JSON.stringify(losses.options) === JSON.stringify(casualtyOptions(losses.forces,losses.dial,losses.support)),
|
||||
JSON.stringify(losses.options) === JSON.stringify(committedCasualtyOptions(losses.forces,losses.dial,losses.support,losses)),
|
||||
'The Nexus Sardaukar casualties have lost their committed force allocation.');
|
||||
if (losses.outcome === 'pending') {
|
||||
const context = g.lastBattleContext;
|
||||
@@ -3838,16 +3854,16 @@ function nexusSardaukarIntegrity(g: Game) {
|
||||
requireRule(decisions.length > 0 || committed || rescue?.losses, 'The Nexus Sardaukar battle has lost its casualty choice.');
|
||||
if (rescue) requireRule(rescue.player === record.receipt.owner &&
|
||||
rescue.turn === record.receipt.turn && rescue.territory === record.receipt.territory &&
|
||||
JSON.stringify(rescue.commitment) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}) &&
|
||||
JSON.stringify(rescue.commitment) === JSON.stringify(commitment) &&
|
||||
(!rescue.losses || losses.options.some(option => JSON.stringify(option) === JSON.stringify(rescue.losses))),
|
||||
'The Suk rescue differs from the committed Nexus Sardaukar casualties.');
|
||||
for (const decision of decisions) requireRule(decision.kind === 'battleLosses' &&
|
||||
decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options),
|
||||
'The casualty choices differ from the committed Nexus Sardaukar plan.');
|
||||
if (committed) requireRule(JSON.stringify(committed) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}),
|
||||
if (committed) requireRule(JSON.stringify(committed) === JSON.stringify(commitment),
|
||||
'The suspended Sardaukar casualty allocation was changed.');
|
||||
if (g.homeworldBattleLoss) requireRule(JSON.stringify(g.homeworldBattleLoss.commitment) ===
|
||||
JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support}),
|
||||
JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,...constraints}),
|
||||
'The Homeworld Sardaukar casualties have lost their effective combat roles.');
|
||||
}
|
||||
}
|
||||
@@ -7308,7 +7324,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The mandatory battle discard cause is inconsistent.',
|
||||
);
|
||||
if (c.casualties) {
|
||||
const { forces, dial, support, options, fullyCommitted } = c.casualties;
|
||||
const { forces, dial, support, options, fullyCommitted, physicalLossCount } = c.casualties;
|
||||
const winner = g.players.find((p) => p.id === c.winner);
|
||||
const forceOwner = g.players.find((p) => p.id === (c.casualties!.owner ?? c.winner));
|
||||
const pool = forceOwner ? combatArmy(g, forceOwner.id, c.territory) : { normal: 0, elite: 0 };
|
||||
@@ -7320,6 +7336,8 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
winner.ally === forceOwner.id &&
|
||||
(winner.faction === 'ecaz' || forceOwner.faction === 'ecaz'))) &&
|
||||
(fullyCommitted === undefined || fullyCommitted === true) &&
|
||||
(physicalLossCount === undefined || (Number.isSafeInteger(physicalLossCount) &&
|
||||
physicalLossCount >= 0 && physicalLossCount <= total)) &&
|
||||
forces &&
|
||||
validCombatForces(forces) &&
|
||||
Number.isSafeInteger(forces.normal) &&
|
||||
@@ -7337,10 +7355,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
Array.isArray(options) &&
|
||||
options.length > 0 &&
|
||||
JSON.stringify(options) ===
|
||||
JSON.stringify(fullyCommitted
|
||||
? casualtyOptions(forces, dial, support).filter(option =>
|
||||
option.normal === forces.normal && option.elite === forces.elite)
|
||||
: casualtyOptions(forces, dial, support)),
|
||||
JSON.stringify(committedCasualtyOptions(forces, dial, support, {fullyCommitted, physicalLossCount})),
|
||||
'The saved battle casualties do not match the committed dial, support and effective forces.',
|
||||
);
|
||||
}
|
||||
@@ -8263,11 +8278,11 @@ function homeworldBattleLossIntegrity(g: Game) {
|
||||
} else {
|
||||
const commitment = pending.commitment;
|
||||
requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite &&
|
||||
(commitment.fullyCommitted === undefined || commitment.fullyCommitted === true),
|
||||
(commitment.fullyCommitted === undefined || commitment.fullyCommitted === true) &&
|
||||
(commitment.physicalLossCount === undefined || (Number.isSafeInteger(commitment.physicalLossCount) &&
|
||||
commitment.physicalLossCount >= 0 && commitment.physicalLossCount <= pool.normal + pool.elite)),
|
||||
'The Homeworld winner needs its committed physical forces.');
|
||||
options = casualtyOptions(commitment.forces, commitment.dial, commitment.support)
|
||||
.filter(choice => !commitment.fullyCommitted ||
|
||||
choice.normal === commitment.forces.normal && choice.elite === commitment.forces.elite)
|
||||
options = committedCasualtyOptions(commitment.forces, commitment.dial, commitment.support, commitment)
|
||||
.map(({normal, elite}) => ({normal, elite}));
|
||||
}
|
||||
requireRule(options.length > 0 && JSON.stringify(options) === JSON.stringify(pending.options),
|
||||
@@ -14506,7 +14521,8 @@ function validateKaramaUse(
|
||||
'The canceled allied traitor call needs its original revealed battle.',
|
||||
);
|
||||
if (traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined))
|
||||
currentBattleResolutionQuote(g, owner.id);
|
||||
if (g.battle!.stoneAllocationVersion !== 1 || !currentStoneAllocationQuote(g, owner.id))
|
||||
currentBattleResolutionQuote(g, owner.id);
|
||||
}
|
||||
if (use.response.kind === 'moritaniRetention')
|
||||
validateMoritaniCancellation(g, use.response.owner);
|
||||
@@ -21450,6 +21466,7 @@ function stonePlanBlock(
|
||||
b.attacker === p.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
b.stoneAllocationVersion === 1,
|
||||
),
|
||||
)
|
||||
.find(Boolean) ??
|
||||
@@ -21484,6 +21501,7 @@ function stoneCompulsionBlock(g: Game, target: Player): string | null {
|
||||
b.attacker === target.id ? 'attacker' : 'defender',
|
||||
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
|
||||
context,
|
||||
b.stoneAllocationVersion === 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -22923,6 +22941,7 @@ function revealedWeaponAnswered(
|
||||
function battleSpecialWeaponIntegrity(g: Game) {
|
||||
const b = g.battle;
|
||||
if (!b) return;
|
||||
stoneAllocationIntegrity(g);
|
||||
for (const id of [b.attacker, b.defender]) {
|
||||
const cardId = b.plans[id]?.weapon;
|
||||
if (cardId !== 'richese-stone-burner' && cardId !== 'richese-mirror-weapon') continue;
|
||||
@@ -23255,7 +23274,7 @@ function smugglerBattleIntegrity(g: Game) {
|
||||
'The completed Smuggler collection changed its original battle receipt.');
|
||||
}
|
||||
/** Prepare current server-only inputs once; the pure module cannot inspect Game or mutate it. */
|
||||
function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
function currentBattleResolutionInput(g: Game, canceledVoter?: string): BattleResolutionInput {
|
||||
const b = g.battle!;
|
||||
const combatant = (id: string, opponentId: string): ResolutionCombatant => {
|
||||
const p = getPlayer(g, id),
|
||||
@@ -23293,43 +23312,50 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}),
|
||||
};
|
||||
};
|
||||
const attacker = combatant(b.attacker, b.defender);
|
||||
const defender = combatant(b.defender, b.attacker);
|
||||
const homeworld = currentHomeworldBattleRules(g, b.territory);
|
||||
if (homeworld) {
|
||||
const native = homeworld.native === attacker.id ? attacker :
|
||||
homeworld.native === defender.id ? defender : null;
|
||||
if (native) homeworld.nativeForces = { normal: native.forces.normal, elite: native.forces.elite };
|
||||
}
|
||||
return {
|
||||
advanced: g.advanced,
|
||||
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
||||
aggressor: battleAggressor(g),
|
||||
typedCasualties: !!g.homeworlds,
|
||||
...(homeworld ? { homeworld } : {}),
|
||||
turn: g.turn,
|
||||
territory: b.territory,
|
||||
attacker,
|
||||
defender,
|
||||
voters: traitorVoters(g, b).map((id) => ({
|
||||
id,
|
||||
beneficiary: traitorBeneficiary(g, b, id)!,
|
||||
called: id === canceledVoter ? false : b.traitorCalls[id],
|
||||
traitors: b.traitorDeclarations?.[id] ? [b.traitorDeclarations[id].identity] : getPlayer(g, id).traitors,
|
||||
})),
|
||||
participants: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
ally: p.ally,
|
||||
noFieldAtTerritory:
|
||||
p.noField?.deployed?.location.territory === b.territory,
|
||||
})),
|
||||
physicalCards: physicalTreacheryCards(g),
|
||||
pendingAuditorPresent: !!g.pendingAuditor,
|
||||
pendingRetentionPresent: !!g.moritaniRetention,
|
||||
nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'),
|
||||
...(b.stoneAllocation ? { stoneAllocations: b.stoneAllocation.selections } : {}),
|
||||
};
|
||||
}
|
||||
function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
const b = g.battle!;
|
||||
try {
|
||||
const attacker = combatant(b.attacker, b.defender);
|
||||
const defender = combatant(b.defender, b.attacker);
|
||||
const homeworld = currentHomeworldBattleRules(g, b.territory);
|
||||
if (homeworld) {
|
||||
const native = homeworld.native === attacker.id ? attacker :
|
||||
homeworld.native === defender.id ? defender : null;
|
||||
if (native) homeworld.nativeForces = { normal: native.forces.normal, elite: native.forces.elite };
|
||||
}
|
||||
const quote = quoteBattleResolution({
|
||||
advanced: g.advanced,
|
||||
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
||||
aggressor: battleAggressor(g),
|
||||
typedCasualties: !!g.homeworlds,
|
||||
...(homeworld ? { homeworld } : {}),
|
||||
turn: g.turn,
|
||||
territory: b.territory,
|
||||
attacker,
|
||||
defender,
|
||||
voters: traitorVoters(g, b).map((id) => ({
|
||||
id,
|
||||
beneficiary: traitorBeneficiary(g, b, id)!,
|
||||
called: id === canceledVoter ? false : b.traitorCalls[id],
|
||||
traitors: b.traitorDeclarations?.[id] ? [b.traitorDeclarations[id].identity] : getPlayer(g, id).traitors,
|
||||
})),
|
||||
participants: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
ally: p.ally,
|
||||
noFieldAtTerritory:
|
||||
p.noField?.deployed?.location.territory === b.territory,
|
||||
})),
|
||||
physicalCards: physicalTreacheryCards(g),
|
||||
pendingAuditorPresent: !!g.pendingAuditor,
|
||||
pendingRetentionPresent: !!g.moritaniRetention,
|
||||
nexusMoritani: !!g.nexusCards?.cards && !byFaction(g, 'moritani'),
|
||||
});
|
||||
const input = currentBattleResolutionInput(g, canceledVoter);
|
||||
const homeworld = input.homeworld;
|
||||
const quote = quoteBattleResolution(input);
|
||||
for (const withdrawal of quote.harassWithdraw ?? [])
|
||||
validateReserveReturnCounters(getPlayer(g, withdrawal.player), withdrawal.returned);
|
||||
if (quote.sukGraduate) requireRule(
|
||||
@@ -23415,6 +23441,8 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
|
||||
const forces = combatForces(g, p, b.territory, opponent);
|
||||
const occupy = ecazBattleProfile(g);
|
||||
const combined = occupy?.lead === player && !occupy.canceled;
|
||||
const committedStoneLosses = b.stoneAllocation?.complete && !(combined && player === occupy.ecaz)
|
||||
? stoneBurnerCommittedLosses(currentBattleResolutionInput(g), player) : null;
|
||||
const dial = combined
|
||||
? player === occupy.ecaz ? occupy.fixedEcazDial : plan.dial - occupy.fixedEcazDial
|
||||
: plan.dial;
|
||||
@@ -23436,7 +23464,10 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) {
|
||||
const elite = p.elites?.forces[origin] ?? 0;
|
||||
return { normal: sum.normal + count - elite, elite: sum.elite + elite };
|
||||
}, { normal: 0, elite: 0 });
|
||||
return { location: key, choices: diplomatRetreatChoices(forces, dial, support, strength, available) };
|
||||
const choices = diplomatRetreatChoices(forces, dial, support, strength, available)
|
||||
.filter(choice => !committedStoneLosses || committedStoneLosses.some(loss =>
|
||||
loss.normal + choice.normal <= forces.normal && loss.elite + choice.elite <= forces.elite));
|
||||
return { location: key, choices };
|
||||
})
|
||||
.filter(option => option.choices.length);
|
||||
return destinations.length ? { player, destinations } : null;
|
||||
@@ -23465,8 +23496,76 @@ function diplomatRetreatIntegrity(g: Game) {
|
||||
JSON.stringify(decision.destinations) === JSON.stringify(receipt.destinations),
|
||||
'The saved Diplomat retreat changed its battle, forces, outcome or legal destinations.');
|
||||
}
|
||||
function currentStoneAllocationQuote(g: Game, canceledVoter?: string) {
|
||||
try {
|
||||
return quoteStoneBurnerAllocations(currentBattleResolutionInput(g, canceledVoter));
|
||||
} catch (error) {
|
||||
if (error instanceof BattleResolutionQuoteError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
/** Only the revealed physical commitment is frozen; unrelated hands and wallets are not. */
|
||||
function stoneAllocationContext(g: Game) {
|
||||
const b = g.battle!, input = currentBattleResolutionInput(g);
|
||||
const physical = (side: ResolutionCombatant) => ({
|
||||
player: side.id, faction: side.faction, forces: side.forces, plan: side.plan,
|
||||
stoneMode: side.stoneMode, harass: side.harassWithdraw, harassSelection: side.harassSelection,
|
||||
});
|
||||
return JSON.stringify({ battle: b.event, turn: g.turn, territory: b.territory,
|
||||
attacker: physical(input.attacker), defender: physical(input.defender),
|
||||
occupy: input.ecazOccupy, aggressor: input.aggressor,
|
||||
traitors: b.traitorCalls, weaponModes: b.poisonTooth });
|
||||
}
|
||||
function stoneAllocationIntegrity(g: Game) {
|
||||
const b = g.battle, frame = b?.stoneAllocation;
|
||||
if (!frame) {
|
||||
requireRule(g.decision?.kind !== 'stoneAllocation', 'The physical-count choice lost its Stone battle.');
|
||||
return;
|
||||
}
|
||||
requireRule(b && b.stoneAllocationVersion === 1 && g.status === 'playing' && g.phase === 6 &&
|
||||
b.revealed && b.event === frame.battle && frame.turn === g.turn && frame.event &&
|
||||
frame.context === stoneAllocationContext(g),
|
||||
'The Stone physical-count commitment changed its original revealed battle.');
|
||||
const offer = currentStoneAllocationQuote(g);
|
||||
requireRule(offer && frame.choices.length === offer.choices.length &&
|
||||
frame.choices.every(choice => offer.choices.some(candidate => JSON.stringify(candidate) === JSON.stringify(choice))) &&
|
||||
Object.entries(frame.selections).every(([player, total]) =>
|
||||
frame.choices.some(choice => choice.player === player && choice.totals.includes(total))),
|
||||
'The Stone physical-count choices no longer match their original legal pools.');
|
||||
const pending = frame.choices.find(choice => !Object.hasOwn(frame.selections, choice.player));
|
||||
requireRule(!!frame.complete === !pending,
|
||||
'Finish every private Stone count before exposing the comparison.');
|
||||
if (pending) requireRule(homeworldSavedDecisions(g).some(decision => decision.kind === 'stoneAllocation' &&
|
||||
decision.event === frame.event && decision.player === pending.owner),
|
||||
'The unfinished Stone count lost its owned decision.');
|
||||
}
|
||||
function offerStoneAllocation(g: Game): boolean {
|
||||
const b = g.battle!;
|
||||
if (b.stoneAllocationVersion !== 1 || !revealedWeaponChoices(g).some(choice => choice.kind === 'stoneBurner')) return false;
|
||||
const offer = currentStoneAllocationQuote(g);
|
||||
if (!offer) return false;
|
||||
b.stoneAllocation ??= {
|
||||
event: crypto.randomUUID(), battle: b.event!, turn: g.turn,
|
||||
context: stoneAllocationContext(g), selections: {},
|
||||
choices: [...offer.choices].sort((a, d) => g.order.indexOf(a.owner) - g.order.indexOf(d.owner)),
|
||||
};
|
||||
const frame = b.stoneAllocation;
|
||||
const choice = frame.choices.find(candidate => !Object.hasOwn(frame.selections, candidate.player));
|
||||
if (choice) {
|
||||
g.decision = {kind: 'stoneAllocation', player: choice.owner, event: frame.event};
|
||||
return true;
|
||||
}
|
||||
if (!frame.complete) {
|
||||
frame.complete = true;
|
||||
log(g, `Stone Burner physical counts are committed: ${frame.choices.map(candidate =>
|
||||
`${getPlayer(g, candidate.player).name} ${frame.selections[candidate.player]} undialed`).join('; ')}. The winner's later typed losses must retain that physical count.`,
|
||||
{faction:getPlayer(g, b.attacker).faction,name:'Stone Burner physical counts'});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function advanceBattleOutcome(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (offerStoneAllocation(g)) return;
|
||||
const quote = currentBattleResolutionQuote(g);
|
||||
if (quote.smugglerAllocation || quote.sandmasterSelection) {
|
||||
g.decision = quote.smugglerAllocation ?? quote.sandmasterSelection;
|
||||
@@ -23502,6 +23601,16 @@ function currentJacurutuIncome(g: Game, quote: ReturnType<typeof currentBattleRe
|
||||
? casualtyOptions(forces,plan.dial,plan.support).find(choice =>
|
||||
choice.normal === withdrawal.remaining.normal && choice.elite === withdrawal.remaining.elite)
|
||||
: undefined;
|
||||
const sentToTanks = forces ? {
|
||||
normal: forces.normal - (withdrawal?.returned.normal ?? 0) - (retreat?.normal ?? 0),
|
||||
elite: forces.elite - (withdrawal?.returned.elite ?? 0) - (retreat?.elite ?? 0),
|
||||
} : null;
|
||||
const committedStoneOptions = opponent && b.stoneAllocation?.complete
|
||||
? stoneBurnerCommittedLosses(currentBattleResolutionInput(g), opponent.id) : null;
|
||||
const committedStoneDial = committedStoneOptions?.find(option => sentToTanks &&
|
||||
option.normal <= sentToTanks.normal && option.elite <= sentToTanks.elite);
|
||||
requireRule(!committedStoneOptions || committedStoneDial,
|
||||
'The Jacurutu loss and retreat must retain the committed Stone physical count.');
|
||||
requireRule(!withdrawal || withdrawalDial, 'Harass & Withdraw must retain its original physical dial.');
|
||||
return nexusRule(() => quoteJacurutuBattleIncome({
|
||||
territory: b.territory,
|
||||
@@ -23511,11 +23620,8 @@ function currentJacurutuIncome(g: Game, quote: ReturnType<typeof currentBattleRe
|
||||
opponentForces: forces,
|
||||
opponentDial: plan?.dial ?? null,
|
||||
opponentSupport: plan?.support ?? null,
|
||||
sentToTanks: forces ? {
|
||||
normal: forces.normal - (withdrawal?.returned.normal ?? 0) - (retreat?.normal ?? 0),
|
||||
elite: forces.elite - (withdrawal?.returned.elite ?? 0) - (retreat?.elite ?? 0),
|
||||
} : null,
|
||||
selectedDial: b.jacurutuDial?.selectedDial ?? withdrawalDial,
|
||||
sentToTanks,
|
||||
selectedDial: b.jacurutuDial?.selectedDial ?? withdrawalDial ?? committedStoneDial,
|
||||
}));
|
||||
}
|
||||
function jacurutuDialIntegrity(g: Game) {
|
||||
@@ -23997,7 +24103,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
pool: combatArmy(g, owner, b.territory),
|
||||
options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})),
|
||||
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support,
|
||||
...(casualtyCommitment.fullyCommitted ? { fullyCommitted: true as const } : {}) } } : {}),
|
||||
...(casualtyCommitment.fullyCommitted ? { fullyCommitted: true as const } : {}),
|
||||
...(casualtyCommitment.physicalLossCount !== undefined ? { physicalLossCount: casualtyCommitment.physicalLossCount } : {}) } } : {}),
|
||||
};
|
||||
}
|
||||
if (discarded.length) {
|
||||
@@ -24242,7 +24349,8 @@ function sukRescueIntegrity(g: Game) {
|
||||
JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(g, player, pending.territory)) &&
|
||||
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
|
||||
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
|
||||
JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) &&
|
||||
JSON.stringify(pending.commitment.options) === JSON.stringify(committedCasualtyOptions(
|
||||
pending.commitment.forces, pending.commitment.dial, pending.commitment.support, pending.commitment)) &&
|
||||
pending.commitment.options.length > 0 && pending.cards.every((id) => player.hand.some((c) => c.id === id)),
|
||||
'The saved Suk Graduate rescue no longer matches its battle, cards or physical forces.');
|
||||
if (pending.losses) {
|
||||
@@ -24274,7 +24382,8 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, d
|
||||
record.receipt.battle === pending.event && record.receipt.owner === pending.player);
|
||||
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
|
||||
JSON.stringify(pending.commitment) === JSON.stringify({forces:sardaukar.casualties.forces,
|
||||
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options}) &&
|
||||
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options,
|
||||
...casualtyConstraintFields(sardaukar.casualties)}) &&
|
||||
sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)),
|
||||
'The Suk rescue lost its original Nexus Sardaukar casualty allocation.');
|
||||
let physical: SukPhysicalRescueQuote | undefined;
|
||||
@@ -31844,6 +31953,18 @@ function applyActionInner(
|
||||
if (destination === null)
|
||||
log(g, `${p.name} declined the Diplomat retreat.`, { faction: p.faction, name: 'Diplomat retreat' });
|
||||
resolveBattle(g, { destination: destination as string | null, normal: normal as number, elite: elite as number });
|
||||
} else if (decision.kind === 'stoneAllocation') {
|
||||
const frame = g.battle?.stoneAllocation;
|
||||
const choice = frame?.choices.find(candidate => !Object.hasOwn(frame.selections, candidate.player));
|
||||
requireRule(frame && choice && choice.owner === id && !frame.complete &&
|
||||
decision.event === frame.event && action.event === frame.event &&
|
||||
Object.keys(action).every(key => ['type','event','undialed'].includes(key)) &&
|
||||
typeof action.undialed === 'number' && choice.totals.includes(action.undialed),
|
||||
'Commit one offered undialed physical count for the current revealed Stone battle.');
|
||||
frame.selections[choice.player] = action.undialed;
|
||||
log(g, `${p.name} committed a Stone Burner physical count. Counts remain sealed until every required side commits.`,
|
||||
{faction:p.faction,name:'Stone Burner commitment'});
|
||||
advanceBattleOutcome(g);
|
||||
} else if (decision.kind === 'jacurutuDial') {
|
||||
const frame = g.battle?.jacurutuDial;
|
||||
requireRule(frame && frame.owner === id && frame.event === decision.event && action.event === decision.event &&
|
||||
@@ -34149,6 +34270,7 @@ function applyActionInner(
|
||||
...choice,
|
||||
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
|
||||
event: battleEvent,
|
||||
stoneAllocationVersion: 1,
|
||||
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 3 as const } : {}),
|
||||
@@ -35687,9 +35809,19 @@ export function viewGame(state: Game, id: string) {
|
||||
: null,
|
||||
poisonTooth: b.revealed ? (b.poisonTooth ?? {}) : {},
|
||||
stoneBurner: b.revealed ? (b.stoneBurner ?? {}) : {},
|
||||
stoneAllocation: b.stoneAllocation?.choices.some(choice => choice.owner === id)
|
||||
? (() => {
|
||||
const choice = b.stoneAllocation!.choices.find(candidate => candidate.owner === id &&
|
||||
!Object.hasOwn(b.stoneAllocation!.selections, candidate.player)) ??
|
||||
b.stoneAllocation!.choices.find(candidate => candidate.owner === id)!;
|
||||
return {event:b.stoneAllocation!.event,player:choice.player,owner:choice.owner,totals:[...choice.totals],
|
||||
selected:b.stoneAllocation!.selections[choice.player] ?? null};
|
||||
})()
|
||||
: null,
|
||||
stoneBurnerContext: [b.attacker, b.defender].includes(id)
|
||||
? {
|
||||
blocked: stoneTimingBlock(g),
|
||||
allowAllocations: b.stoneAllocationVersion === 1,
|
||||
occupy: stoneOccupyContext(g),
|
||||
opponentPools: stonePublicPools(
|
||||
g,
|
||||
|
||||
+16
-7
@@ -1130,8 +1130,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Compare the number of physical force tokens not dialed into battle. The aggressor wins a tie unless the actual held Habbanya Ridge Sietch advantage decides it. Dialed losses and spice support still follow their normal rules; elite strength is not an extra physical token.',
|
||||
'With Harass & Withdraw, this prototype provisionally compares undialed tokens remaining after successful withdrawal. An opposing successful Traitor cancels the return and decides the outcome instead. In Occupy, only the card user’s pool returns; original fixed Ecaz commitment and the other ally’s undialed counters remain. This timing is an explicit implementation interpretation, not a publisher ruling.',
|
||||
'In the existing native Advanced Occupy prototype, Reinforcements still pays its original own-reserve cost with same-plan, opposing or Mirror-copied Stone. Its +2 normal-score bonus adds no physical undialed tokens, dial, support or on-board casualties. Original mode choices, Traitor precedence and physical Stone/Mirror retention remain unchanged; other profile/module boundaries stay.',
|
||||
'The plan editor checks dial/support against every public opposing force possibility, never a hidden plan or No-Field value. Winner-changing physical allocations remain guarded. Clean selected CHOAM/Ix profiles now reuse the existing provisional original Stone-first, then uncopied Tooth order; Mirror copies still choose before their originals as printed. The earlier priority question remains unanswered.',
|
||||
'Clean selected Ix/CHOAM retains original Tech3+ and/or Advanced Strongholds2+, using genuine setup, delayed income and earned end-Mentat custody. Original47 and independent50 decks keep the separate Richese cache. Homeworlds, Nexus, Skills, Discoveries, active Sandtrout and mixed E3 with these phase modules stay guarded. Native Ecaz/Moritani combinations remain Advanced-only; other non-Ix behavior and public starts are unchanged.',
|
||||
'New battles with winner-changing physical allocations provisionally ask each ambiguous side to commit its undialed physical total after revealed modes and actual withdrawal. All required values stay sealed until everyone commits. The winner chooses compatible ordinary/elite losses afterward. Ordinary and invariant-winner battles keep their usual later casualty choice. Older battles retain their original public-only guard. No hidden opposing plan or No-Field value is inspected to decide precommit availability.',
|
||||
'Clean selected CHOAM/Ix profiles reuse the existing provisional original Stone-first, then uncopied Tooth order; Mirror copies still choose before their originals as printed. The earlier priority question remains unanswered. Physical-count commitment timing is also an explicit prototype procedure, rather than a publisher clarification.',
|
||||
'Selected Ix/CHOAM retains original Tech3+ and Advanced Strongholds2+, with genuine setup, delayed income and earned end-Mentat custody. Original47 and independent50 decks keep the separate Richese cache. Native optional-module and Ecaz/Moritani integrations retain their existing setup requirements and preview warnings; a physical-count commitment does not certify every combination.',
|
||||
'Traitor and Lasgun–shield explosion precedence remain intact. Ordinary bounty applies unless another effect suppresses it. Stone Burner uses the general winning battle-card retention rule: a winner may keep it, and a losing played card is discarded. Normal Moritani ally retention may apply.',
|
||||
'The post-reveal choice is bound to the current battle event and cannot be repeated after refresh. Review both revealed plans and select one mode; there is no separate victim or death confirmation.',
|
||||
],
|
||||
@@ -1146,32 +1147,40 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Canonical weapon admission, public pre-commit safety, persisted reveal choice, physical-token comparison, provisional post-Harass comparison and ordinary cleanup are integrated. Public fixed Ecaz withdrawal alternatives are checked without hidden-card reads. Ambiguous combinations remain guarded.',
|
||||
'New original Basic/Advanced mixed-force and copied Stone comparisons collect sealed physical totals only when the winner depends on them. Later typed losses, mandatory cleanup and native aftercare retain that count. Ordinary/invariant and legacy behavior remains; broader combinations and complete modes stay unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Weapon and prescience selectors include Stone Burner; the editor explains chosen dial/support restrictions, provides an inspector and exposes both reveal modes.',
|
||||
'Weapon/prescience selectors, inspectors and both reveal modes remain. The owned physical-total control explains provisional sealed timing and stays visible until commitment; later matching ordinary/elite loss choices remain separate.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All profiles generate guarded low-dial Stone candidates and choose a reveal mode from public force/leader information. Broader expansion strategy remains unverified.',
|
||||
'All profiles generate legal Stone candidates, choose a reveal mode and commit an offered own physical total. Native two-sided programmes verify both owned choices; strategic refinement and calibration remain deferred.',
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Explains physical-token victory, both leader choices, ordinary winner retention and explicit combined timing limits.',
|
||||
'Explains physical-token victory, both leader choices, sealed total commitments, later typed losses and the explicitly provisional timing/source boundary.',
|
||||
},
|
||||
{
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Earlier pure/engine/review/recovery evidence remains separate. Genuine native cache acquisition, paid marker/typed attack and earned Habbanya now cover physical fees/losses and original/copied retention. Two mobile journeys/20 actions reach Collection;32 legal policy programmes validate870 candidates/256 continuations. All32 naturally call a held Traitor; normal physical outcomes are separate Node/UI proof, not complete modes or calibration.',
|
||||
'The physical-total checkpoint passes frozen16-file146/146, types/lint/build and14 original-action/rendered-control programmes: paid native Basic/Advanced Stone, actual next-turn Mirror, both mixed sides in either seat order, both winners and matching later typed alternatives. Four policies make both private choices legally. Earlier mobile/marker/Habbanya evidence stays dated. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; comprehensive combinations, assurance and complete modes remain open.',
|
||||
evidence: [
|
||||
'game/stone-burner-allocation.ts',
|
||||
'game/battle-casualty-commitment.ts',
|
||||
'components/stone-burner-allocation.tsx',
|
||||
'tests/stone-burner-allocation.test.ts',
|
||||
'tests/stone-burner-allocation-engine.test.ts',
|
||||
'tests/stone-burner-allocation-controls.test.tsx',
|
||||
'tests/fixture-stone-burner-allocation.ts',
|
||||
'docs/STONE_BURNER_ENGINE_AUDIT.md',
|
||||
'tests/stone-burner.test.ts',
|
||||
'tests/stone-burner-engine.test.ts',
|
||||
'tests/stone-burner-review.test.ts',
|
||||
|
||||
@@ -62,6 +62,7 @@ const DECISIONS = {
|
||||
techToken: true,
|
||||
poisonTooth: true,
|
||||
stoneBurner: true,
|
||||
stoneAllocation: true,
|
||||
fullPlanOffer: true,
|
||||
fullPlanRead: true,
|
||||
guildShipment: true,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
quoteBattleResolution,
|
||||
StoneBurnerAllocationRequiredError,
|
||||
validateStoneBurnerAllocationSelections,
|
||||
type BattleResolutionInput,
|
||||
} from './battle-resolution-quote';
|
||||
import type { Casualties } from './combat';
|
||||
|
||||
export type StoneBurnerAllocationChoice = Readonly<{
|
||||
/** Stable battle side key; may differ from the physical variable owner. */
|
||||
player: string;
|
||||
owner: string;
|
||||
totals: readonly number[];
|
||||
}>;
|
||||
export type StoneBurnerAllocationQuote = Readonly<{
|
||||
choices: readonly StoneBurnerAllocationChoice[];
|
||||
}>;
|
||||
/** Calculator-owned native variable options before either side is destroyed. */
|
||||
export type StoneBurnerAllocationLoss = Readonly<{ undialed: number; losses: Casualties }>;
|
||||
export { validateStoneBurnerAllocationSelections } from './battle-resolution-quote';
|
||||
|
||||
/** Ask only for an actual unresolved Stone outcome after original effective
|
||||
* weapons, successful traitors and withdrawals. A minimum legal completion is
|
||||
* used solely to run the same remaining validation; no outcome is selected or
|
||||
* returned by this quote and no state, resource or random stream is changed.
|
||||
* Return the original full roster even with partial/full valid commitments so
|
||||
* the caller can retain sealed simultaneous information until all are ready. */
|
||||
function allocationPrerequisite(input: BattleResolutionInput): StoneBurnerAllocationRequiredError | null {
|
||||
try {
|
||||
quoteBattleResolution({ ...input, stoneAllocations: undefined });
|
||||
} catch (error) {
|
||||
if (!(error instanceof StoneBurnerAllocationRequiredError)) throw error;
|
||||
const selected = validateStoneBurnerAllocationSelections(error.choices, input.stoneAllocations);
|
||||
const completion = Object.fromEntries(error.choices.map(choice => [choice.player,
|
||||
Object.hasOwn(selected, choice.player) ? selected[choice.player] : choice.totals[0]]));
|
||||
// Reuse native funding, outcome, Occupy and physical custody validation.
|
||||
quoteBattleResolution({ ...input, stoneAllocations: completion });
|
||||
return error;
|
||||
}
|
||||
validateStoneBurnerAllocationSelections([], input.stoneAllocations);
|
||||
return null;
|
||||
}
|
||||
export function quoteStoneBurnerAllocations(input: BattleResolutionInput): StoneBurnerAllocationQuote | null {
|
||||
const prerequisite = allocationPrerequisite(input);
|
||||
return prerequisite ? { choices: prerequisite.choices.map(choice => ({ ...choice, totals: [...choice.totals] })) } : null;
|
||||
}
|
||||
|
||||
/** Stable side key, including a coalition lead, selects the same original
|
||||
* variable loss options used by Stone. Fixed Ecaz and withdrawn complements
|
||||
* are already represented by the calculator's authoritative physical total. */
|
||||
export function stoneBurnerCommittedLosses(input: BattleResolutionInput, player: string): readonly Casualties[] | null {
|
||||
if (!input.stoneAllocations || !Object.hasOwn(input.stoneAllocations, player)) return null;
|
||||
const prerequisite = allocationPrerequisite(input);
|
||||
return prerequisite!.losses[player]
|
||||
.filter(option => option.undialed === input.stoneAllocations![player])
|
||||
.map(option => ({ ...option.losses }));
|
||||
}
|
||||
+20
-2
@@ -4,6 +4,7 @@ import {
|
||||
maxCombatDial,
|
||||
maxCombatSupport,
|
||||
type CombatForces,
|
||||
type Casualties,
|
||||
} from './combat';
|
||||
import type { HarassWithdrawOccupyContext } from './harass-withdraw';
|
||||
|
||||
@@ -54,6 +55,18 @@ function validSide(side: StoneBurnerSide): boolean {
|
||||
function sorted(values: Iterable<number>): number[] {
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
/** The score uses physical undialed counters after the original withdrawal,
|
||||
* including only the surviving fixed Ecaz contribution. Paid support is not
|
||||
* another casualty and temporary roles retain their ordinary physical type. */
|
||||
export function stoneBurnerUndialedTotal(
|
||||
forces: CombatForces,
|
||||
losses: Pick<Casualties, 'normal' | 'elite'>,
|
||||
context?: StoneBurnerSideContext,
|
||||
): number {
|
||||
const fixedUndialed = context?.withdrawal === 'ecaz' ? 0 : context?.ecazUndialed ?? 0;
|
||||
return context?.withdrawal === 'variable' ? fixedUndialed
|
||||
: fixedUndialed + forces.normal + forces.elite - losses.normal - losses.elite;
|
||||
}
|
||||
function totals(
|
||||
forces: CombatForces,
|
||||
dial: number,
|
||||
@@ -78,7 +91,7 @@ function totals(
|
||||
if (context?.withdrawal === 'variable') return [fixedUndialed];
|
||||
return sorted(
|
||||
losses.map(
|
||||
(loss) => fixedUndialed + forces.normal + forces.elite - loss.normal - loss.elite,
|
||||
(loss) => stoneBurnerUndialedTotal(forces, loss, context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -181,6 +194,7 @@ export function stoneBurnerPlanBlock(
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
allowAllocations = false,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
@@ -191,6 +205,9 @@ export function stoneBurnerPlanBlock(
|
||||
const ownTotals = totals(own, dial, support, ownOccupy);
|
||||
if (!ownTotals.length)
|
||||
return 'Your dial and support do not permit a legal Stone Burner force allocation.';
|
||||
// New battles finish ambiguous outcomes through separate sealed undialed
|
||||
// totals. Legacy battles retain the original public-only preflight boundary.
|
||||
if (allowAllocations === true) return null;
|
||||
const possibleFixedReturn = opponentOccupy?.possibleEcazWithdrawal &&
|
||||
opponentOccupy.withdrawal !== 'ecaz' ? opponentOccupy.ecazUndialed ?? 0 : 0;
|
||||
for (const otherTotals of allOpposingTotals(opponent, opponentOccupy)) {
|
||||
@@ -215,6 +232,7 @@ export function stoneBurnerCompulsionBlock(
|
||||
side: StoneBurnerSide,
|
||||
aggressor: StoneBurnerSide = 'attacker',
|
||||
context?: StoneBurnerContext,
|
||||
allowAllocations = false,
|
||||
): string | null {
|
||||
if (!validSide(side) || !validSide(aggressor))
|
||||
return 'Choose valid Stone Burner combatant roles.';
|
||||
@@ -226,7 +244,7 @@ export function stoneBurnerCompulsionBlock(
|
||||
for (let halfDial = fixedDial * 2; halfDial <= maximumStrength * 2; halfDial++) {
|
||||
if (!totals(own, halfDial / 2, 0, ownOccupy).length) continue;
|
||||
if (
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor, context) ===
|
||||
stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor, context, allowAllocations) ===
|
||||
null
|
||||
)
|
||||
return null;
|
||||
|
||||
@@ -20,9 +20,11 @@ export type SukRescueReceipt = {
|
||||
player: string;
|
||||
territory: string;
|
||||
skill: SukGraduateSkill;
|
||||
commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[] };
|
||||
commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[];
|
||||
fullyCommitted?: true; physicalLossCount?: number };
|
||||
/** Ecaz-led Occupy settles the ally's variable losses before this own-force rescue. */
|
||||
occupyCasualties?: { owner: string; forces: CombatForces; dial: number; support: number; options: Casualties[] };
|
||||
occupyCasualties?: { owner: string; forces: CombatForces; dial: number; support: number; options: Casualties[];
|
||||
fullyCommitted?: true; physicalLossCount?: number };
|
||||
pool: SukForceGroup[];
|
||||
cards: string[];
|
||||
physical: string;
|
||||
|
||||
Reference in new issue
Block a user