Compose Harass withdrawal with provisional Stone Burner timing

This commit is contained in:
Quintine committed 2026-10-16 06:35:04 +11:00
1 parent 5d13374f7e
commit feb2b03251
23 files changed
+1362 -124

No files matched your search

+49 -39
View File
@@ -26,6 +26,8 @@ import {
} from './moritani-retention';
import {
stoneBurnerComparison,
stoneBurnerWithdrawalContext,
type StoneBurnerContext,
type StoneBurnerComparison,
} from './stone-burner';
import { matchingTraitor } from './traitors';
@@ -423,8 +425,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
if (harass) {
requireQuote(validBattleSlotPair(slots[0], slots[1]),
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
requireQuote(side.harassWithdraw && !input.homeworld && !hasStone,
'Harass & Withdraw needs its supported physical context; Homeworld and Stone Burner combinations remain unfinished.');
requireQuote(side.harassWithdraw && !input.homeworld,
'Harass & Withdraw needs its supported physical context and cannot combine with Homeworld battles.');
const profile = input.ecazOccupy?.lead === side.id ? input.ecazOccupy : undefined;
const normalization = side.harassWithdraw.occupy;
if (profile) {
@@ -445,43 +447,6 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
withdrawals.set(side.id, quoteHarassWithdraw(side.harassWithdraw, side.plan.dial, side.plan.support, side.harassSelection));
}
}
const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
[a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
? 'attacker' : 'defender';
const stoneOccupy = hasStone && input.ecazOccupy ? {
fixedEcazDial: input.ecazOccupy.fixedEcazDial,
ecazUndialed: input.ecazOccupy.canceled ? 0 : Math.floor(input.ecazOccupy.ecazForces.normal / 2),
} : undefined;
const stone = hasStone
? stoneBurnerComparison(
a.forces,
a.plan.dial,
a.plan.support,
d.forces,
d.plan.dial,
d.plan.support,
tie,
input.ecazOccupy ? {
attacker: input.ecazOccupy.lead === a.id ? stoneOccupy : undefined,
defender: input.ecazOccupy.lead === d.id ? stoneOccupy : undefined,
} : undefined,
)
: null;
if (stone) {
requireQuote(
!!stone.winner,
'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
);
for (const [side, weapon] of [
[a, weapons.attacker],
[d, weapons.defender],
] as const)
requireQuote(
weapon.kind !== 'stoneBurner' ||
['kill', 'ignore'].includes(side.stoneMode ?? ''),
'Choose Stone Burner’s revealed mode before resolving battle.',
);
}
requireQuote(
Array.isArray(input.voters) &&
new Set(input.voters.map((v) => v.id)).size === input.voters.length &&
@@ -528,6 +493,51 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
return withdrawal && !(side === a ? dc : ac)
? [{ ...withdrawal, player: side.id, card: HARASS_WITHDRAW_CARD }] : [];
});
const tie = battleTieOwner(a.id, d.id, input.aggressor ?? a.id,
[a, d].filter(side => side.stronghold === 'habbanya_ridge_sietch').map(side => side.id)) === a.id
? 'attacker' : 'defender';
const stoneOccupy = hasStone && input.ecazOccupy ? {
fixedEcazDial: input.ecazOccupy.fixedEcazDial,
ecazUndialed: input.ecazOccupy.canceled ? 0 : Math.floor(input.ecazOccupy.ecazForces.normal / 2),
} : undefined;
let stoneContext: StoneBurnerContext | undefined = stoneOccupy ? {
attacker: input.ecazOccupy!.lead === a.id ? stoneOccupy : undefined,
defender: input.ecazOccupy!.lead === d.id ? stoneOccupy : undefined,
} : undefined;
if (hasStone)
for (const side of [a, d])
if (harassWithdraw.some(withdrawal => withdrawal.player === side.id))
stoneContext = stoneBurnerWithdrawalContext(
stoneContext, side === a ? 'attacker' : 'defender', side.harassWithdraw?.occupy,
);
const stone = hasStone
? stoneBurnerComparison(
a.forces,
a.plan.dial,
a.plan.support,
d.forces,
d.plan.dial,
d.plan.support,
tie,
stoneContext,
)
: null;
if (stone) {
requireQuote(
stone.attacker.length > 0 && stone.defender.length > 0 &&
(!!stone.winner || ac || dc),
'Stone Burner has an unresolved physical casualty allocation. No battle resources were spent.',
);
for (const [side, weapon] of [
[a, weapons.attacker],
[d, weapons.defender],
] as const)
requireQuote(
weapon.kind !== 'stoneBurner' ||
['kill', 'ignore'].includes(side.stoneMode ?? ''),
'Choose Stone Burner’s revealed mode before resolving battle.',
);
}
const spiceBankerPayments = [a, d].filter(side => (side.plan.bankerSpice ?? 0) > 0).map(side => {
const waivedByTraitor = side === a ? ac && !dc : dc && !ac;
return { player: side.id, amount: waivedByTraitor ? 0 : side.plan.bankerSpice!, waivedByTraitor };
+19 -3
View File
@@ -112,7 +112,7 @@ import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
import { stoneBurnerPlanBlock, stoneBurnerComparison } from './stone-burner';
import { stoneBurnerPlanBlock, stoneBurnerComparison, stoneBurnerWithdrawalContext } from './stone-burner';
import {
applyAction,
normalizeAutomaticGame,
@@ -956,6 +956,11 @@ function plans(g: GameView): Action[] {
const stonePlan = isStoneBurner(me.hand?.find((c) => c.id === action.weapon)) ||
isMirrorWeapon(me.hand?.find((c) => c.id === action.weapon));
if (stonePlan && !action.leader) continue;
const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
const stoneContext = stonePlan && harass
? stoneBurnerWithdrawalContext(b.stoneBurnerContext?.occupy,
b.attacker === me.id ? 'attacker' : 'defender', b.harassWithdraw?.occupy)
: b.stoneBurnerContext?.occupy;
if (
stonePlan &&
(!b.stoneBurnerContext ||
@@ -969,13 +974,12 @@ function plans(g: GameView): Action[] {
pool,
b.attacker === me.id ? 'attacker' : 'defender',
b.tieWinner === b.attacker ? 'attacker' : 'defender',
b.stoneBurnerContext?.occupy,
stoneContext,
),
))
)
continue;
let withdrawn = 0;
const harass = weapon === 'ecaz-harass-withdraw' || defense === 'ecaz-harass-withdraw';
if (harass) {
if (!b.harassWithdraw) continue;
try {
@@ -2406,6 +2410,16 @@ function policyActions(g: GameView): Action[] {
!!matchingTraitor(me.traitors ?? [], other.leader, card(other.leader));
const side = me.id === b.attacker ? 'attacker' : 'defender';
const pools = b.stoneBurnerContext?.opponentPools ?? [];
let context = b.stoneBurnerContext?.occupy;
for (const player of [b.attacker, b.defender]) {
const plan = b.plans[player];
if (![plan.weapon, plan.defense].includes('ecaz-harass-withdraw')) continue;
const occupy = b.ecazOccupy?.lead === player ? {
cardUser: player, ecaz: b.ecazOccupy.ecaz, canceled: b.ecazOccupy.canceled,
} : undefined;
context = stoneBurnerWithdrawalContext(context,
player === b.attacker ? 'attacker' : 'defender', occupy);
}
const winners = b.ownForces
? pools.map((pool) =>
side === 'attacker'
@@ -2417,6 +2431,7 @@ function policyActions(g: GameView): Action[] {
other.dial,
other.support,
b.tieWinner === b.attacker ? 'attacker' : 'defender',
context,
).winner
: stoneBurnerComparison(
pool,
@@ -2426,6 +2441,7 @@ function policyActions(g: GameView): Action[] {
own.dial,
own.support,
b.tieWinner === b.attacker ? 'attacker' : 'defender',
context,
).winner,
)
: [];
+1 -1
View File
@@ -120,7 +120,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
availability: ecaz.card.effect === 'recruits'
? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
: ecaz.card.effect === 'harassWithdraw'
? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Richese card combinations and additional optional modules remain guarded.'
? 'Harass & Withdraw has development controls in either battle-card slot, with physical allocation choices after reveal. Stone Burner provisionally compares undialed forces remaining after successful withdrawal; additional optional modules remain guarded.'
: 'Reinforcements has development controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three of the card user’s own reserve forces and adds +2 battle score without extra spice support. Charging that cost on every revealed outcome is provisional; same-plan Harass & Withdraw, Stone Burner and other optional modules remain guarded.',
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
};
+29 -20
View File
@@ -378,7 +378,7 @@ import {
auditCount,
sampleAuditCards,
} from './choam-auditor';
import { stoneBurnerPlanBlock, type StoneBurnerContext } from './stone-burner';
import { stoneBurnerPlanBlock, stoneBurnerWithdrawalContext, type StoneBurnerContext } from './stone-burner';
import {
STRONGHOLD_CARDS,
createStrongholdCards,
@@ -20692,21 +20692,20 @@ function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candi
return fixedBattleInspectionMatches(field, fixed as string | number | null, candidate,
(field === 'weapon' || field === 'defense') && typeof candidate === 'string' ? cardOf(p, candidate) : undefined);
}
function harassModuleBlock(g: Game): string | null {
if (!g.ecazTreachery)
return 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
return 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
return combinedCardBattleBlock(g);
}
function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
const b = g.battle!;
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const combinedBlock = combinedCardBattleBlock(g);
let blocked: string | null = null;
if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id))
let blocked = harassModuleBlock(g);
if (!blocked && (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id)))
blocked = 'Use Harass & Withdraw in your own battle.';
else if (g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.discoveryEnabled)
blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined)
blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.';
else if (combinedBlock)
blocked = combinedBlock;
else if (p.noField?.deployed?.location.territory === b.territory)
if (!blocked && p.noField?.deployed?.location.territory === b.territory)
blocked = 'Reveal your No-Field before using Harass & Withdraw.';
const locations = Object.fromEntries(Object.entries(p.forces)
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
@@ -21049,26 +21048,36 @@ function stoneTimingBlock(g: Game): string | null {
function stoneOccupyContext(g: Game): StoneBurnerContext | undefined {
const b = g.battle!, profile = ecazBattleProfile(g);
if (!profile || profile.canceled) return undefined;
const side = { fixedEcazDial: profile.fixedEcazDial, ecazUndialed: Math.floor(profile.ecazForces.normal / 2) };
const side = {
fixedEcazDial: profile.fixedEcazDial,
ecazUndialed: Math.floor(profile.ecazForces.normal / 2),
// Configuration and selected lead are public; never inspect the sealed
// opponent card to decide whether its fixed undialed Ecaz forces may return.
...(profile.lead === profile.ecaz && !harassModuleBlock(g)
? { possibleEcazWithdrawal: true } : {}),
};
return profile.lead === b.attacker ? { attacker: side } : { defender: side };
}
function stonePlanBlock(
g: Game,
p: Player,
dial: number,
support: number,
plan: Pick<Plan, 'dial' | 'support' | 'weapon' | 'defense'>,
): string | null {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const context = stoneOccupyContext(g);
const baseContext = stoneOccupyContext(g);
const context = [plan.weapon, plan.defense].includes(HARASS_WITHDRAW_CARD)
? stoneBurnerWithdrawalContext(baseContext, b.attacker === p.id ? 'attacker' : 'defender',
harassWithdrawContext(g, p).occupy)
: baseContext;
return (
stoneTimingBlock(g) ??
stonePublicPools(g, opponent, p)
.map((pool) =>
stoneBurnerPlanBlock(
planCombatForces(g, p, opponent),
dial,
support,
plan.dial,
plan.support,
pool,
b.attacker === p.id ? 'attacker' : 'defender',
battleTieWinner(g) === b.attacker ? 'attacker' : 'defender',
@@ -21249,11 +21258,11 @@ function validatePlan(
}
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
if (isMirrorWeapon(w)) {
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, dial, support);
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, plan);
requireRule(!blocked, blocked ?? 'Mirror Weapon cannot resolve this commitment.');
}
if (isStoneBurner(w)) {
const blocked = stonePlanBlock(g, p, dial, support);
const blocked = stonePlanBlock(g, p, plan);
requireRule(
!blocked,
blocked ?? 'Stone Burner cannot resolve this commitment.',
+10 -6
View File
@@ -1106,6 +1106,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Any faction holding canonical Stone Burner may commit it as a Weapon / Special. It is a named special weapon for Voice, not a poison or projectile weapon. Prescience reveals its actual card identity while other plan elements stay private.',
'After both plans reveal, choose to kill both leaders or ignore the strength of leaders who otherwise survive. Neither leader strength nor Kwisatz Haderach strength determines this battle. The choice affects deaths and bounty, not which undialed-force comparison is used. Ignoring strength supplies no protection against another weapon.',
'Compare the number of physical force tokens not dialed into battle. The aggressor wins a tie. 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.',
'The plan editor checks the chosen dial and support against every projected public opposing force possibility. If legal force allocations could change the winner, commitment is blocked until that combined timing is supported. Ix expansion timing involving Poison Tooth also remains guarded pending its ordering ruling. Opposing hidden No-Field values are not inspected.',
'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.',
@@ -1121,7 +1122,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Canonical weapon admission, public pre-commit safety, persisted reveal choice, physical-token comparison and ordinary cleanup are integrated in development fixtures. Ambiguous combinations remain guarded.',
'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.',
},
{
area: 'Player controls',
@@ -1152,6 +1153,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/stone-burner-review.test.ts',
'tests/stone-burner-bots.test.ts',
'tests/stone-burner-recovery.test.ts',
'tests/stone-harass-quote.test.ts',
'tests/stone-harass-runtime.test.ts',
'tests/ecaz-occupy-harass-quote.test.ts',
],
},
],
@@ -4969,16 +4973,16 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Atreides asks for that weapon or defense category, answer None without exposing Harass & Withdraw. The category is fixed, while the special card may occupy its physical slot. A literal Truthtrance promise that a slot is empty still means no physical card.',
'Return your undialed ordinary and elite forces to reserves. Your leader can still be killed. An opponent’s successful Traitor call cancels withdrawal, including mutual calls. A Face Dancer cannot replace the forces already returned.',
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Richese card combinations and additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Stone Burner provisionally compares the undialed forces remaining after successful withdrawal, including same-plan, opposing and copied Stone. Additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, revealed typed/sector return selection, exact committed losses, opponent-Traitor cancellation, mandatory disposal and saved continuation are connected. Richese and optional-module combinations remain gated.', evidence: ['game/harass-withdraw.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment and card inspection are usable. Multiple legal typed or sector returns have an owner choice after plans reveal and before traitor declarations; unique returns are automatic.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical slot commitment, category inspection, revealed typed/sector returns, exact committed losses, opponent-Traitor cancellation, mandatory disposal and continuation are connected. Richese/Stone uses provisional post-withdrawal comparison with original fixed Occupy costs; other optional modules remain gated.', evidence: ['game/harass-withdraw.ts', 'game/stone-burner.ts', 'game/battle-card-slots.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Either slot, compatible pair selection, category-null commitment and card inspection are usable. Owners select typed/sector returns after reveal; Stone guidance and revealed counts visibly explain provisional post-withdrawal timing and Traitor cancellation.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles have a legal shared-quote path and preserve inspection secrecy. Retreat strategy and combined-game strength remain uncalibrated.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The card guide distinguishes printed effects, explicit inference and unfinished physical allocation/Stone Burner combinations.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the prototype; final sample and browser evidence remains bounded, with full combination acceptance open.', evidence: ['tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The guide distinguishes printed effects, explicit inference, provisional post-withdrawal Stone timing and remaining module boundaries.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused Stone/Harass and Occupy quote/native regressions cover winner reversal, original costs, typed returns, Mirror retention and Traitor precedence. Two actual mobile native-control journeys and legal four-profile smoke are bounded evidence, not full combination or deployed acceptance. Historical recovery proof is separate.', evidence: ['tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
],
},
{
+62 -27
View File
@@ -5,6 +5,7 @@ import {
maxCombatSupport,
type CombatForces,
} from './combat';
import type { HarassWithdrawOccupyContext } from './harass-withdraw';
export type StoneBurnerSide = 'attacker' | 'defender';
export type StoneBurnerComparison = {
@@ -17,12 +18,33 @@ export type StoneBurnerOccupyContext = {
fixedEcazDial: number;
ecazUndialed: number;
};
export type StoneBurnerSideContext = Partial<StoneBurnerOccupyContext> & {
withdrawal?: 'variable' | 'ecaz';
/** Public precommit possibility only; never inferred from a hidden hand. */
possibleEcazWithdrawal?: boolean;
};
/** Context belongs to physical battle slots, not the card holder or aggressor. */
export type StoneBurnerContext = {
attacker?: StoneBurnerOccupyContext;
defender?: StoneBurnerOccupyContext;
attacker?: StoneBurnerSideContext;
defender?: StoneBurnerSideContext;
};
/** Only the card user's own undialed counters leave the physical battle. */
export function stoneBurnerWithdrawalContext(
context: StoneBurnerContext | undefined,
side: StoneBurnerSide,
occupy?: Pick<HarassWithdrawOccupyContext, 'canceled' | 'cardUser' | 'ecaz'>,
): StoneBurnerContext {
return {
...context,
[side]: {
...context?.[side],
withdrawal: occupy && !occupy.canceled && occupy.cardUser === occupy.ecaz
? 'ecaz' : 'variable',
},
};
}
const opposingChoices = new Map<string, readonly (readonly number[])[]>();
const MAX_CACHED_POOLS = 128;
@@ -36,10 +58,10 @@ function totals(
forces: CombatForces,
dial: number,
support: number,
occupy?: StoneBurnerOccupyContext,
context?: StoneBurnerSideContext,
): number[] {
dial -= occupy?.fixedEcazDial ?? 0;
const fixedUndialed = occupy?.ecazUndialed ?? 0;
dial -= context?.fixedEcazDial ?? 0;
const fixedUndialed = context?.withdrawal === 'ecaz' ? 0 : context?.ecazUndialed ?? 0;
if (
!validCombatForces(forces) ||
!Number.isFinite(dial) ||
@@ -50,8 +72,12 @@ function totals(
support > maxCombatSupport(forces)
)
return [];
// Validate the original commitment before removing its undialed complement.
const losses = casualtyOptions(forces, dial, support);
if (!losses.length) return [];
if (context?.withdrawal === 'variable') return [fixedUndialed];
return sorted(
casualtyOptions(forces, dial, support).map(
losses.map(
(loss) => fixedUndialed + forces.normal + forces.elite - loss.normal - loss.elite,
),
);
@@ -62,13 +88,15 @@ function invariantWinner(
attacker: readonly number[],
defender: readonly number[],
aggressor: StoneBurnerSide,
attackerOffset = 0,
defenderOffset = 0,
): StoneBurnerSide | null {
if (!attacker.length || !defender.length || !validSide(aggressor))
return null;
const aMin = attacker[0],
aMax = attacker[attacker.length - 1];
const dMin = defender[0],
dMax = defender[defender.length - 1];
const aMin = attacker[0] + attackerOffset,
aMax = attacker[attacker.length - 1] + attackerOffset;
const dMin = defender[0] + defenderOffset,
dMax = defender[defender.length - 1] + defenderOffset;
if (aMin > dMax || (aMin === dMax && aggressor === 'attacker'))
return 'attacker';
if (dMin > aMax || (dMin === aMax && aggressor === 'defender'))
@@ -103,10 +131,10 @@ export function stoneBurnerComparison(
*/
function allOpposingTotals(
forces: CombatForces,
occupy?: StoneBurnerOccupyContext,
context?: StoneBurnerSideContext,
): readonly (readonly number[])[] {
const fixedUndialed = occupy?.ecazUndialed ?? 0;
const key = [
const fixedUndialed = context?.withdrawal === 'ecaz' ? 0 : context?.ecazUndialed ?? 0;
const key = context?.withdrawal === 'variable' ? `variable:${fixedUndialed}` : [
forces.normal,
forces.elite,
forces.eliteStrength,
@@ -116,24 +144,25 @@ function allOpposingTotals(
forces.freeSupport,
!!forces.eliteFreeSupport,
fixedUndialed,
context?.withdrawal ?? '',
].join(':');
const cached = opposingChoices.get(key);
if (cached) return cached;
const result: number[][] = [];
const maximumHalfDial = maxCombatDial(forces) * 2;
const maximumSupport = maxCombatSupport(forces);
// Use the same legality and typed-support arithmetic as sealed plans. Cache
// public pool results so repeated preflights do not repeat this enumeration.
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
for (let support = 0; support <= maximumSupport; support++) {
const choices = totals(forces, halfDial / 2, support);
if (choices.length) {
if (fixedUndialed)
for (let index = 0; index < choices.length; index++)
choices[index] += fixedUndialed;
result.push(choices);
// Once the variable pool has withdrawn, every feasible original commitment
// leaves the same undialed total, regardless of its dial or support.
if (context?.withdrawal === 'variable') result.push([fixedUndialed]);
else {
const maximumHalfDial = maxCombatDial(forces) * 2;
const maximumSupport = maxCombatSupport(forces);
// Use the same legality and typed-support arithmetic as sealed plans. Cache
// public pool results so repeated preflights do not repeat this enumeration.
for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++)
for (let support = 0; support <= maximumSupport; support++) {
const choices = totals(forces, halfDial / 2 + (context?.fixedEcazDial ?? 0), support, context);
if (choices.length) result.push(choices);
}
}
}
if (opposingChoices.size >= MAX_CACHED_POOLS)
opposingChoices.delete(opposingChoices.keys().next().value!);
opposingChoices.set(key, result);
@@ -162,12 +191,18 @@ 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.';
const possibleFixedReturn = opponentOccupy?.possibleEcazWithdrawal &&
opponentOccupy.withdrawal !== 'ecaz' ? opponentOccupy.ecazUndialed ?? 0 : 0;
for (const otherTotals of allOpposingTotals(opponent, opponentOccupy)) {
const winner =
side === 'attacker'
? invariantWinner(ownTotals, otherTotals, aggressor)
: invariantWinner(otherTotals, ownTotals, aggressor);
if (winner === null)
const withdrawnWinner = !possibleFixedReturn ? winner :
side === 'attacker'
? invariantWinner(ownTotals, otherTotals, aggressor, 0, -possibleFixedReturn)
: invariantWinner(otherTotals, ownTotals, aggressor, -possibleFixedReturn, 0);
if (winner === null || withdrawnWinner === null)
return 'Stone Burner can change the winner with different legal force allocations; this combined allocation timing is not yet supported.';
}
return null;