← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed3
Improved2
Regressed1
Added0
Retired0
Testcase Delta
199 / 199
Code / Config Changes
Commits (ed76d7ae → 08216739)
08216739gen8 hero drafts, uiimprovemetns, small other bitspiddlyminx15/07/2026, 05:21:32
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Gatot.json===================================================================--- simulator/config/hero_definitions/Gatot.json prev run+++ simulator/config/hero_definitions/Gatot.json this run@@ -0,0 +1,104 @@+{+ "name": "Gatot",+ "hero_generation": "S8",+ "troop_type": "infantry",+ "skills": {+ "GoldenGuard": {+ "description": "Gatot commands his troops with imperial guard tactics, increasing his Infantry's Defense by X%.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "GoldenGuard/1": {+ "type": "active.hero.defense.up",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": [+ "infantry"+ ]+ }+ }+ }+ },+ "KingsBestowal": {+ "description": "The great kings bless Gatot's Infantry, granting Infantry a Shield with protection equal to Attack * X% each time they attack, for 1 turn.",+ "trigger": {+ "type": "attack",+ "source": "infantry"+ },+ "effects": {+ "KingsBestowal/1": {+ "type": "active.hero.damageTaken.down",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "units": {+ "applies_to": "trigger.source"+ },+ "duration": {+ "turns": {+ "count": 1+ }+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "RoyalLegion": {+ "description": "Gatot's formidable legion instills fear in enemies, reducing their Attack by X%.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "RoyalLegion/1": {+ "type": "active.hero.attack.down",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ },+ "IndestructibleCity": {+ "description": "Widget bonus: Increase Defense by X% for all troops when defending.",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "IndestructibleCity/1": {+ "type": "passive.defense.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Hendrik.json===================================================================--- simulator/config/hero_definitions/Hendrik.json prev run+++ simulator/config/hero_definitions/Hendrik.json this run@@ -0,0 +1,107 @@+{+ "name": "Hendrik",+ "hero_generation": "S8",+ "troop_type": "marksman",+ "skills": {+ "WormsRavage": {+ "description": "Captain Hendrik commands a gigantic naval shipworm to gnaw at the enemies' armor, reducing all enemy troops' Defense by X%.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "WormsRavage/1": {+ "type": "active.hero.defense.down",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "units": {+ "applies_to": "enemy.any"+ }+ }+ }+ },+ "ArmorOfBarnacles": {+ "description": "Hendrik covers all friendly Troops with a layer of hard-shelled barnacles every 4 turns, increasing their Defense by X% for 2 turns.",+ "trigger": {+ "type": "turn",+ "every": 4+ },+ "effects": {+ "ArmorOfBarnacles/1": {+ "type": "active.hero.defense.up",+ "value": [+ 6,+ 12,+ 18,+ 24,+ 30+ ],+ "duration": {+ "turns": {+ "count": 2+ }+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "DragonsHeir": {+ "description": "Every 3 turns, the ancient abyssal spirit's descendants will work together with Hendrik's Marksmen to launch an attack, dealing X% damage to all enemies.",+ "trigger": {+ "type": "turn",+ "every": 3,+ "source": "marksman"+ },+ "effects": {+ "DragonsHeir/1": {+ "type": "active.hero.damage.up",+ "value": [+ 8,+ 16,+ 24,+ 32,+ 40+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "attacks": {+ "count": 1+ }+ }+ }+ }+ },+ "AbyssalBlessing": {+ "description": "Widget bonus: Increase Attack by X% for all troops when rallying.",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "rally"+ }+ ],+ "effects": {+ "AbyssalBlessing/1": {+ "type": "passive.attack.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_definitions/Sonya.json===================================================================--- simulator/config/hero_definitions/Sonya.json prev run+++ simulator/config/hero_definitions/Sonya.json this run@@ -0,0 +1,136 @@+{+ "name": "Sonya",+ "hero_generation": "S8",+ "troop_type": "lancer",+ "skills": {+ "TreasureHunter": {+ "description": "Everyone has a share when the legendary ocean treasure is found! Sonya's promise inspires everyone, increasing Damage by X% for all troops.",+ "trigger": {+ "type": "battle_start"+ },+ "effects": {+ "TreasureHunter/1": {+ "type": "active.hero.damage.up",+ "value": [+ 4,+ 8,+ 12,+ 16,+ 20+ ]+ }+ }+ },+ "BountyTemptation": {+ "description": "Sonya motivates her Troops with bounty, making her Lancers deal X% more damage every 2 attacks and increasing Attack by Y% for all troops for 1 turn.",+ "trigger": {+ "type": "attack",+ "every": 2,+ "source": "lancer"+ },+ "effects": {+ "BountyTemptation/1": {+ "type": "active.hero.damage.up",+ "value": [+ 15,+ 30,+ 45,+ 60,+ 75+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "attacks": {+ "count": 1+ }+ }+ },+ "BountyTemptation/2": {+ "type": "active.hero.attack.up",+ "value": [+ 5,+ 10,+ 15,+ 20,+ 25+ ],+ "duration": {+ "turns": {+ "count": 1+ }+ },+ "same_effect_stacking": "max"+ }+ }+ },+ "TorrentialImpact": {+ "description": "Sonya's Lancers will seize every chance to launch a surprise raid like underwater currents. Her Lancers deal X% damage every 5 turns and stun the target for 1 turn.",+ "trigger": {+ "type": "turn",+ "every": 5,+ "source": "lancer"+ },+ "effects": {+ "TorrentialImpact/1": {+ "type": "active.hero.damage.up",+ "value": [+ 50,+ 100,+ 150,+ 200,+ 250+ ],+ "units": {+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"+ },+ "duration": {+ "attacks": {+ "count": 1+ }+ }+ },+ "TorrentialImpact/2": {+ "type": "no_attack",+ "units": {+ "applies_to": "trigger.target",+ "applies_vs": "trigger.source"+ },+ "duration": {+ "turns": {+ "count": 1+ }+ }+ }+ }+ },+ "VortexTurret": {+ "description": "Widget bonus: Increase Lethality by X% for all troops when defending.",+ "trigger": {+ "type": "battle_start"+ },+ "requirements": [+ {+ "level": 1,+ "type": "engagement_type",+ "value": "garrison"+ }+ ],+ "effects": {+ "VortexTurret/1": {+ "type": "passive.lethality.up",+ "value": [+ 5,+ 7.5,+ 10,+ 12.5,+ 15+ ]+ }+ }+ }+ }+}Index: simulator/config/hero_generation_stats.json===================================================================--- simulator/config/hero_generation_stats.json prev run+++ simulator/config/hero_generation_stats.json this run@@ -55,6 +55,12 @@"attack": 650.52,"defense": 650.52,"lethality": 160.5,"health": 160.5+ },+ "S8": {+ "attack": 780.62,+ "defense": 780.62,+ "lethality": 193,+ "health": 193}}Index: simulator/src/classifier.ts===================================================================--- simulator/src/classifier.ts prev run+++ simulator/src/classifier.ts this run@@ -1,12 +1,11 @@import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";import { unitMaskHas } from "./types";-import { bucketDefinition, type BucketName } from "./damageBuckets";-import { staticPassiveBucketRole, type StaticPassiveBucket } from "./staticDamageProfile";+import { bucketDefinition, passiveBucketRole, type BucketName, type PassiveBucket } from "./damageBuckets";export interface Classification {kind: "bucket" | "control" | "extra_skill_attack" | "battle_order" | "report_only";- bucket?: BucketName | StaticPassiveBucket;+ bucket?: BucketName | PassiveBucket;control?: "dodge" | "no_attack";reason?: string;}@@ -21,19 +20,20 @@if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };if (type === "attack_order") return { kind: "battle_order" };const definition = bucketDefinition(type);- const staticPassiveRole = staticPassiveBucketRole(type);- if ((!definition || definition.valueType !== "pct") && !staticPassiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };- if (staticPassiveRole) {- if (staticPassiveRole === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };- if (staticPassiveRole === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };+ const passiveRole = passiveBucketRole(type);+ if ((!definition || definition.valueType !== "pct") && !passiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };+ if (passiveRole) {+ if (passiveRole === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: type as PassiveBucket };+ if (passiveRole === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: type as PassiveBucket };return { kind: "report_only", reason: unsupportedReason(effect, job) };}if (!definition) return { kind: "report_only", reason: unsupportedReason(effect, job) };+ if (definition.phase !== "dynamic") return { kind: "report_only", reason: unsupportedReason(effect, job) };if (definition.appliesTo !== undefined && definition.appliesTo !== job.kind) return { kind: "report_only", reason: "not_applicable_to_job_kind" };- if (definition.role === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type };- if (definition.role === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type };+ if (definition.role === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: definition.path };+ if (definition.role === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: definition.path };return { kind: "report_only", reason: unsupportedReason(effect, job) };}export function basicEffectApplies(effect: ActiveEffect, job: DamageJob): boolean {@@ -44,24 +44,24 @@return true;}function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {- const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;- const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;+ const appliesToSide = control === "no_attack" ? job.dealerSide : job.takerSide;+ const appliesToUnit = control === "no_attack" ? job.dealerUnit : job.takerUnit;if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;- const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;- const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;+ const appliesVsSide = control === "no_attack" ? job.takerSide : job.dealerSide;+ const appliesVsUnit = control === "no_attack" ? job.takerUnit : job.dealerUnit;return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);}function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {- if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";- if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";+ if (effect.appliesTo.side === job.dealerSide) return "unsupported_dealer_effect";+ if (effect.appliesTo.side === job.takerSide) return "unsupported_taker_effect";return "wrong_side";}function unitForSide(side: SideId, job: DamageJob): UnitType | undefined {- if (side === job.attackerSide) return job.attackerUnit;- if (side === job.defenderSide) return job.defenderUnit;+ if (side === job.dealerSide) return job.dealerUnit;+ if (side === job.takerSide) return job.takerUnit;return undefined;}Index: simulator/src/classifierDamage.test.ts===================================================================--- simulator/src/classifierDamage.test.ts prev run+++ simulator/src/classifierDamage.test.ts this run@@ -1,13 +1,14 @@import assert from "node:assert/strict";import { test } from "node:test";import { classifyEffectForJob } from "./classifier";-import { calculateDamageJob } from "./damage";-import { ATOMIC_BUCKETS } from "./damageBuckets";-import { createEffectIndex, indexEffect } from "./effectIndex";-import { activateEffect, evolvingActiveEffectValuePct } from "./effects";-import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";+import { calculateDamageJob, createDamageScratch, evaluateDamageExpression } from "./damage";+import { ATOMIC_BUCKETS, DYNAMIC_BUCKETS, PASSIVE_BUCKETS, STATIC_BUCKETS, type BucketPlacement, type BucketRole } from "./damageBuckets";+import { createEffectIndex, damageShapeSlotsForEffect, indexEffect } from "./effectIndex";+import { activateEffect, evolvingActiveEffectValuePct, resolvedEffectScopeKey } from "./effects";+import { buildStaticDamageBucketFactors, buildStaticDamageProfile } from "./staticDamageProfile";+import { createRecorder, type BattleRecorder } from "./recorder";import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";import { ALL_UNIT_MASK, unitMask } from "./types";const job: DamageJob = {@@ -18,12 +19,12 @@roundStartTroops: {attacker: { infantry: 1000, lancer: 0, marksman: 0 },defender: { infantry: 0, lancer: 1000, marksman: 0 }},- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",sourceMultiplier: 1};test("resolved effect scope matches concrete side and unit masks", () => {@@ -50,17 +51,17 @@assert.equal(classifyEffectForJob(active, {...job,id: "job-attacker-marksman",- attackerUnit: "marksman"+ dealerUnit: "marksman"})?.reason,"not_applicable_to_job");assert.equal(classifyEffectForJob(active, {...job,id: "job-defender-marksman",- defenderUnit: "marksman"+ takerUnit: "marksman"})?.reason,"not_applicable_to_job");});@@ -98,20 +99,51 @@function calculateIndexedDamageJob(damageJob: DamageJob,fighters: Record<"attacker" | "defender", ResolvedFighter>,effects: ActiveEffect[],- options: Partial<Parameters<typeof calculateDamageJob>[3]> = {}+ options: Omit<Partial<Parameters<typeof calculateDamageJob>[2]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}) {- const effectIndex = options.effectIndex ?? createEffectIndex();+ const effectIndex = options.effectIndex ?? preparedEffectIndex(effects);if (!options.effectIndex) {- for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);+ for (const activeEffect of effects) {+ if (activeEffect.damageIndexGroup) indexEffect(effectIndex, activeEffect);+ }}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);const usedEffects = options.usedEffects ?? [];- const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });- return { ...result, usedEffectIds: usedEffects.map((usedEffect) => usedEffect.id) };+ const recorder = options.recorder ?? createRecorder(options.trace === true ? "trace" : "standard", [], () => {+ throw new Error("damage-only test recorder has no battle resolution");+ });+ recorder.recordStaticProfile(fighters, effects);+ const { trace: _trace, ...damageOptions } = options;+ const result = calculateDamageJob(damageJob, fighters, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };}+function preparedEffectIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {+ const groups: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];+ const byShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][] = Array.from({ length: 72 }, () => []);+ const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["damageIndexGroup"]>>();+ for (const activeEffect of effects) {+ const slots = damageShapeSlotsForEffect(activeEffect);+ if (slots.length === 0) continue;+ const key = `${activeEffect.stackingKey ?? activeEffect.id}:${resolvedEffectScopeKey(activeEffect.appliesTo, activeEffect.appliesVs)}`;+ let group = byResolvedGroup.get(key);+ if (!group) {+ group = {+ effects: [],+ bucketIndex: ATOMIC_BUCKETS.indexOf(activeEffect.intent.type as never),+ sameEffectStacking: activeEffect.sameEffectStacking+ };+ byResolvedGroup.set(key, group);+ groups.push(group);+ for (const slot of slots) byShape[slot].push(group);+ }+ activeEffect.damageIndexGroup = group;+ }+ return createEffectIndex(groups, byShape);+}+test("classifier routes up/down effects into neutral atomic buckets", () => {assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");assert.equal(classifyEffectForJob(effect("active.hero.health.down", "defender"), job)?.bucket, "active.hero.health.down");assert.equal(classifyEffectForJob(effect("active.hero.attack.up", "attacker"), job)?.bucket, "active.hero.attack.up");@@ -125,9 +157,9 @@assert.equal(classifyEffectForJob(effect("active.troop.lethality.up", "attacker", 10, "troop_skill"), job)?.bucket, "active.troop.lethality.up");});test("classifier routes the complete native bucket policy into atomic buckets", () => {- const atomicBuckets = new Set(ATOMIC_BUCKETS);+ const atomicBuckets = new Set<string>(ATOMIC_BUCKETS);const expected = new Map([["active.hero.lethality.up", "active.hero.lethality.up"],["active.hero.lethality.down", "active.hero.lethality.down"],["active.hero.attack.up", "active.hero.attack.up"],@@ -190,9 +222,9 @@for (const [type, expectedBucket] of passiveExpected) {const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;assert.equal(bucket, expectedBucket, type);- assert.ok(bucket !== undefined && (STATIC_PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a static passive bucket`);+ assert.ok(bucket !== undefined && (PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a passive bucket`);}assert.equal(classifyEffectForJob(effect("passive.attack.down", "attacker", 5), job)?.bucket, "passive.attack.down");});@@ -200,24 +232,24 @@test("passive stat bonuses only apply in the damage role that consumes that stat", () => {const reversedJob: DamageJob = {...job,id: "job-reversed",- attackerSide: "defender",- attackerUnit: "lancer",- defenderSide: "attacker",- defenderUnit: "infantry"+ dealerSide: "defender",+ dealerUnit: "lancer",+ takerSide: "attacker",+ takerUnit: "infantry"};const passiveAttack = effect("passive.attack.up", "attacker", 10);const passiveHealth = effect("passive.health.up", "defender", 10);assert.equal(classifyEffectForJob(passiveAttack, job)?.bucket, "passive.attack.up");- assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_defender_effect");+ assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_taker_effect");assert.equal(classifyEffectForJob(passiveHealth, job)?.bucket, "passive.health.up");- assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_attacker_effect");+ assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_dealer_effect");});test("damage calculator requires indexed effect candidates", () => {- assert.throws(() => calculateDamageJob(job, simpleFighters(), [], { trace: true } as never), /effectIndex/i);+ assert.throws(() => calculateDamageJob(job, simpleFighters(), { trace: true } as never), /effectIndex/i);});test("damage calculator uses centralized bucket definitions for player stat routing", () => {const attacker: ResolvedFighter = {@@ -276,9 +308,9 @@});test("traced damage uses the effect index for applied bucket candidates", () => {const indexedEffect = effect("active.hero.attack.up", "attacker", 100);- const index = createEffectIndex();+ const index = preparedEffectIndex([indexedEffect]);indexEffect(index, indexedEffect);const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });const outcome = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true, effectIndex: index });@@ -300,14 +332,14 @@const outcome = calculateIndexedDamageJob(job, fighters, [passiveAttack(20), passiveAttack(10), passiveAttack(-5)], { trace: true });assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.3);- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.down"].factor, 1.05);+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.3);+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.down"].factor, 1.05);assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));});-test("static damage profile produces the same static bucket damage as per-job passive processing", () => {+test("static profile factoring is identical to evaluating its buckets unfactored", () => {const fighters = simpleFighters();fighters.attacker.statBonuses.infantry.attack = 50;fighters.attacker.statBonuses.infantry.lethality = 25;fighters.defender.statBonuses.lancer.health = 40;@@ -320,20 +352,91 @@effect("passive.health.up", "defender", 15),effect("passive.health.down", "defender", 30),effect("passive.defense.up", "defender", 5)];- const baseline = calculateIndexedDamageJob(job, fighters, passives, { trace: true });- const profile = buildStaticDamageProfile(fighters, passives);- const profiled = calculateIndexedDamageJob(job, fighters, passives, { trace: true, staticDamageProfile: profile });+ const runtimeEffects = [+ effect("active.hero.attack.up", "attacker", 15),+ effect("active.troop.damage.down", "attacker", 12),+ effect("active.hero.health.down", "defender", 8),+ effect("type.normal.damageTaken.up", "defender", 7)+ ];+ const allEffects = [...passives, ...runtimeEffects];+ const bucketFactors = buildStaticDamageBucketFactors(fighters, allEffects);+ const profile = buildStaticDamageProfile(fighters, allEffects);+ const scratch = createDamageScratch();+ const profiled = calculateIndexedDamageJob(job, fighters, allEffects, {+ trace: true,+ staticDamageProfile: profile,+ scratch,+ capToTakerTroops: false+ });+ const dealerBuckets = bucketFactors[job.dealerSide][job.dealerUnit];+ const takerBuckets = bucketFactors[job.takerSide][job.takerUnit];+ const dealer = profile[job.dealerSide][job.dealerUnit];+ const taker = profile[job.takerSide][job.takerUnit];- assert.ok(Math.abs(profiled.kills - baseline.kills) < 1e-12);+ assert.equal(+ dealer.dealerFactor.factors[0],+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") /+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator")+ );+ assert.equal(+ taker.takerFactor.factors[0],+ unfactoredStaticProduct(takerBuckets, "taker", "numerator") /+ unfactoredStaticProduct(takerBuckets, "taker", "denominator")+ );+ assert.equal(+ evaluateDamageExpression(dealer.dealerFactor, taker.takerFactor).factors[0],+ dealer.dealerFactor.factors[0] * taker.takerFactor.factors[0]+ );+ const unfactoredStaticNumerator =+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") *+ unfactoredStaticProduct(takerBuckets, "taker", "numerator");+ const unfactoredStaticDenominator =+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator") *+ unfactoredStaticProduct(takerBuckets, "taker", "denominator");+ const unfactoredDamage =+ unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /+ (100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);++ assert.ok(Math.abs(profiled.kills - unfactoredDamage) < 1e-12);assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);- assert.equal(profiled.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.2);+ assert.equal(profiled.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 15);+ assert.equal(profiled.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 7);+ assert.equal(profiled.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.2);});+function unfactoredStaticProduct(+ factors: Float64Array,+ role: BucketRole,+ placement: BucketPlacement+): number {+ return STATIC_BUCKETS.reduce(+ (product, definition, index) =>+ definition.role === role && definition.placement === placement ? product * factors[index] : product,+ 1+ );+}++function unfactoredDynamicProduct(+ factors: Float64Array,+ kind: DamageJob["kind"],+ placement: BucketPlacement+): number {+ return DYNAMIC_BUCKETS.reduce(+ (product, definition, index) => {+ const appliesTo = "appliesTo" in definition ? definition.appliesTo : undefined;+ return definition.placement === placement && (!appliesTo || appliesTo === kind)+ ? product * factors[index]+ : product;+ },+ 1+ );+}+test("static damage profile preserves max stacking for duplicate passive effects", () => {const fighters = simpleFighters();const weaker = { ...effect("passive.attack.up", "attacker", 20), id: "weak", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };const stronger = { ...effect("passive.attack.up", "attacker", 50), id: "strong", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };@@ -398,10 +501,10 @@const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].factor, 2.05);- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].placement, "numerator");+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].factor, 2.05);+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);});test("pass-specific buckets only apply to matching damage job kind", () => {@@ -549,8 +652,33 @@assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));});+test("max-stacked activations with disjoint resolved unit scopes apply independently", () => {+ const infantry = {+ ...effect("active.hero.lethality.up", "attacker", 40),+ id: "max-infantry",+ appliesTo: { side: "attacker" as const, units: unitMask("infantry") },+ stackingKey: "same-resolved-effect",+ sameEffectStacking: "max" as const+ };+ const lancer = {+ ...effect("active.hero.lethality.up", "attacker", 70),+ id: "max-lancer",+ appliesTo: { side: "attacker" as const, units: unitMask("lancer") },+ stackingKey: "same-resolved-effect",+ sameEffectStacking: "max" as const+ };+ const fighters = simpleFighters();+ const infantryOutcome = calculateIndexedDamageJob(job, fighters, [infantry, lancer], { trace: true });+ const lancerOutcome = calculateIndexedDamageJob({ ...job, id: "lancer-job", dealerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });++ assert.equal(infantryOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 40);+ assert.deepEqual(infantryOutcome.usedEffectIds, ["max-infantry"]);+ assert.equal(lancerOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 70);+ assert.deepEqual(lancerOutcome.usedEffectIds, ["max-lancer"]);+});+test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {const active = activateEffect({id: "TargetSkill",@@ -565,19 +693,20 @@{id: "targeted-defense",type: "active.hero.defense.up",value: 50,+ sourceDefinition: { type: "active.hero.defense.up" },units: { applies_to: "trigger.target", applies_vs: "trigger.source" }},1,{id: "intent",round: 1,source: "normal",- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,previousReceivedAttackCount: 0,@@ -605,19 +734,20 @@{id: "targeted-offense",type: "active.hero.lethality.up",value: 50,+ sourceDefinition: { type: "active.hero.lethality.up" },units: { applies_to: "trigger.source", applies_vs: "target" }},1,{id: "intent",round: 1,source: "normal",- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,previousReceivedAttackCount: 0,@@ -644,19 +774,20 @@{id: "targeted-defense",type: "active.hero.defense.up",value: 50,+ sourceDefinition: { type: "active.hero.defense.up" },units: { applies_to: "trigger.target", applies_vs: "target" }},1,{id: "intent",round: 1,source: "normal",- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,previousReceivedAttackCount: 0,Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -176,9 +176,9 @@assert.throws(() => loadSimulatorConfigFromDir(root), /negative.*active\.hero\.attack\.down.*value/i);});-test("loadSimulatorConfig rejects passive effects that are not battle-start static", () => {+test("loadSimulatorConfig rejects effects targeting static buckets unless they are fully static", () => {const turnRoot = writeConfigWithTroopEffect({type: "passive.attack.up",value: 10});@@ -197,14 +197,45 @@value_evolution: { type: "fixed_decay", step: "round", value: 1 }},{ type: "battle_start" });+ const emptyDurationRoot = writeConfigWithTroopEffect(+ {+ type: "passive.attack.up",+ value: 10,+ duration: {}+ },+ { type: "battle_start" }+ );+ const probabilityRoot = writeConfigWithTroopEffect(+ {+ type: "passive.attack.up",+ value: 10+ },+ { type: "battle_start", probability: 50 }+ );- assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /passive.*battle_start/i);- assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /passive.*battle.*duration/i);- assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /passive.*value_evolution/i);+ assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /static bucket passive\.attack\.up.*battle_start/i);+ assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /static bucket passive\.attack\.up.*duration/i);+ assert.throws(() => loadSimulatorConfigFromDir(emptyDurationRoot), /static bucket passive\.attack\.up.*duration/i);+ assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /static bucket passive\.attack\.up.*value_evolution/i);+ assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /static bucket passive\.attack\.up.*probability/i);});+test("loadSimulatorConfig rejects skill effects targeting input-derived static buckets", () => {+ const playerRoot = writeConfigWithTroopEffect(+ { type: "player.attack", value: 10 },+ { type: "battle_start" }+ );+ const troopsRoot = writeConfigWithTroopEffect(+ { type: "troops.baseAttack", value: 10 },+ { type: "battle_start" }+ );++ assert.throws(() => loadSimulatorConfigFromDir(playerRoot), /input-derived static bucket/i);+ assert.throws(() => loadSimulatorConfigFromDir(troopsRoot), /input-derived static bucket/i);+});+test("loadSimulatorConfig rejects invalid trigger_damage_jobs selector strings", () => {const root = writeConfigWithTroopEffect({type: "extra_skill_attack",value: 100,Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -6,12 +6,14 @@import Bahiti from "../config/hero_definitions/Bahiti.json" with { type: "json" };import Bradley from "../config/hero_definitions/Bradley.json" with { type: "json" };import Edith from "../config/hero_definitions/Edith.json" with { type: "json" };import Flint from "../config/hero_definitions/Flint.json" with { type: "json" };+import Gatot from "../config/hero_definitions/Gatot.json" with { type: "json" };import Gordon from "../config/hero_definitions/Gordon.json" with { type: "json" };import Greg from "../config/hero_definitions/Greg.json" with { type: "json" };import Gwen from "../config/hero_definitions/Gwen.json" with { type: "json" };import Hector from "../config/hero_definitions/Hector.json" with { type: "json" };+import Hendrik from "../config/hero_definitions/Hendrik.json" with { type: "json" };import Jasser from "../config/hero_definitions/Jasser.json" with { type: "json" };import Jeronimo from "../config/hero_definitions/Jeronimo.json" with { type: "json" };import Jessie from "../config/hero_definitions/Jessie.json" with { type: "json" };import Ling from "../config/hero_definitions/Ling.json" with { type: "json" };@@ -27,20 +29,20 @@import Reina from "../config/hero_definitions/Reina.json" with { type: "json" };import Renee from "../config/hero_definitions/Renee.json" with { type: "json" };import SeoYoon from "../config/hero_definitions/Seo-yoon.json" with { type: "json" };import Sergey from "../config/hero_definitions/Sergey.json" with { type: "json" };+import Sonya from "../config/hero_definitions/Sonya.json" with { type: "json" };import Wayne from "../config/hero_definitions/Wayne.json" with { type: "json" };import WuMing from "../config/hero_definitions/WuMing.json" with { type: "json" };import Zinman from "../config/hero_definitions/Zinman.json" with { type: "json" };import { UNIT_TYPES } from "./types";import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";-import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";-import { assertStaticPassiveEffectDefinition, isPassiveBucket, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";+import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, isPassiveBucket, PASSIVE_BUCKETS } from "./damageBuckets";const KNOWN_EFFECT_TYPES = new Set([- ...ATOMIC_BUCKETS.filter((bucket) => bucket.startsWith("active.") || bucket.startsWith("type.")),- ...STATIC_PASSIVE_BUCKETS,+ ...DYNAMIC_EFFECT_BUCKETS,+ ...PASSIVE_BUCKETS,"extra_skill_attack","dodge","no_attack","attack_order"@@ -52,12 +54,14 @@Bahiti,Bradley,Edith,Flint,+ Gatot,Gordon,Greg,Gwen,Hector,+ Hendrik,Jasser,Jeronimo,Jessie,Ling,@@ -73,8 +77,9 @@Reina,Renee,"Seo-yoon": SeoYoon,Sergey,+ Sonya,Wayne,WuMing,Zinman} as unknown as Record<string, SkillFile>;@@ -173,9 +178,9 @@validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);- assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);+ validateStaticBucketEffect(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);if (!KNOWN_EFFECT_TYPES.has(type)) {diagnostics.unsupportedEffects.push({file,@@ -254,8 +259,40 @@`native effect units.applies_vs cannot be "all" at ${path}; use "any" for an unrestricted usage gate or trigger_damage_jobs target selectors for multi-target damage`);}+// Static buckets are aggregated once per battle into the static damage profile, so any effect+// that feeds one must itself be known at battle start, deterministic, immutable, and permanent.+// Static buckets outside the passive family (troops/player) are input-derived and cannot be+// produced by a skill at all.+function validateStaticBucketEffect(+ trigger: SkillFile["skills"][string]["trigger"],+ effect: EffectIntentDefinition,+ file: string,+ skillId: string,+ effectId: string+): void {+ const definition = bucketDefinition(effect.type);+ if (definition?.phase !== "static") return;++ const path = `${file}:${skillId}.${effectId}`;+ if (!isPassiveBucket(effect.type)) {+ throw new Error(`effect type ${effect.type} targets an input-derived static bucket and cannot be defined by a skill at ${path}`);+ }+ if (trigger.type !== "battle_start") {+ throw new Error(`effect targeting static bucket ${effect.type} must use a battle_start trigger at ${path}`);+ }+ if (trigger.probability !== undefined) {+ throw new Error(`effect targeting static bucket ${effect.type} cannot define trigger probability at ${path}`);+ }+ if (effect.value_evolution !== undefined) {+ throw new Error(`effect targeting static bucket ${effect.type} cannot define value_evolution at ${path}`);+ }+ if (effect.duration !== undefined) {+ throw new Error(`effect targeting static bucket ${effect.type} cannot define duration at ${path}`);+ }+}+function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {const definition = bucketDefinition(effect.type);if ((!definition || definition.valueType !== "pct") && !isPassiveBucket(effect.type)) return;const values = Array.isArray(effect.value) ? effect.value : [effect.value];Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -1,257 +1,178 @@import type {ActiveEffect,- DamageAggregationGroupTrace,- DamageBucketTrace,+ ActiveEffectGroup,DamageEquationTrace,DamageJob,ResolvedFighter,SideId,UnitType} from "./types";import { UNIT_TYPES } from "./types";-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { advanceEffectAttackDelay, sourceLabel } from "./effects";-import { damageEffectsForJob, type EffectIndex } from "./effectIndex";import {- buildStaticDamageProfile,- type StaticDamageBucket,- type StaticDamageProfile,- type StaticDamageProfileEntry-} from "./staticDamageProfile";+ ATOMIC_BUCKETS,+ DYNAMIC_BUCKETS,+ EVALUATED_DAMAGE_BUCKET,+ STATIC_BUCKETS,+ pctBucketDelta,+ pctBucketFactor,+ rawBucketFactor,+ type AtomicBucket,+ type AggregateBucketSpec,+ type BucketRole,+ type BucketSpec,+ type DamageAggregationBucketSpec,+ type StaticDamageBucket+} from "./damageBuckets";+import { advanceEffectAttackDelay } from "./effects";+import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";+import type { BattleRecorder, DamageJobRecorder } from "./recorder";-type AtomicBuckets = Record<string, DamageBucketTrace>;-type GroupPlacement = "numerator" | "denominator";-type DamageDetail = "full" | "fast";type NumericBucketId = number;interface DamageFactorTerm {- id: string;bucket: NumericBucketId;- bucketName: AtomicBucket;- placement: GroupPlacement;+ role?: BucketRole;+ placement: "numerator" | "denominator";appliesTo?: DamageJob["kind"];}-interface MaxBucketEffectGroup {- selected: ActiveEffect;- effects: ActiveEffect[];+interface NumericDamageBuckets {+ factors: Float64Array;}-interface DamageExpressionResult {- rawDamage: number;- aggregationGroups: Record<string, DamageAggregationGroupTrace>;+interface CompiledDamageExpression {+ numeratorSlots: Int32Array;+ denominatorSlots: Int32Array;}-interface NumericDamageBuckets {+export interface DamageBucketSet {factors: Float64Array;- contributors?: DamageBucketTrace["contributors"][];+ expression: CompiledDamageExpression;}export type DamageScratch = NumericDamageBuckets;+export type StaticDamageBucketFactors = Float64Array;+export type StaticDamageBucketMatrix = Record<SideId, Record<UnitType, StaticDamageBucketFactors>>;-// Lean result of a single damage job: kills plus, only when tracing, the recording detail.+/** Pre-collapsed substitutions for one fighter's possible roles in a damage job. */+export interface StaticDamageProfileEntry {+ dealerFactor: DamageBucketSet;+ takerFactor: DamageBucketSet;+}++export type StaticDamageProfile = Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;++// Lean result of a single damage job: kills plus whatever the selected recorder requested.// Usage charging happens via options.usedEffects (every mode); the heavy per-attack-// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.+// AttackOutcome is assembled by the recorder, not here.export interface DamageResult {kills: number;appliedEffects?: DamageEquationTrace["appliedEffects"];trace?: DamageEquationTrace;}const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;-const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};-const TROOPS_COUNT_INDEX = BUCKET_IDS["troops.count"];-const SOURCE_EXTRA_SKILL_INDEX = BUCKET_IDS["source.extraSkill"];-const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));-const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");-const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");-const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");-const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;-const factorSlots = (terms: DamageFactorTerm[], kind: DamageJob["kind"]): Int32Array =>- Int32Array.from(terms.filter((term) => !term.appliesTo || term.appliesTo === kind).map((term) => term.bucket));-const DEFAULT_NUMERATOR_SLOTS = { normal: factorSlots(DEFAULT_NUMERATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_NUMERATOR_TERMS, "skill") };-const DEFAULT_DENOMINATOR_SLOTS = { normal: factorSlots(DEFAULT_DENOMINATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_DENOMINATOR_TERMS, "skill") };-const PROFILED_NUMERATOR_SLOTS = { normal: factorSlots(PROFILED_NUMERATOR_TERMS, "normal"), skill: factorSlots(PROFILED_NUMERATOR_TERMS, "skill") };-const PROFILED_DENOMINATOR_SLOTS = DEFAULT_DENOMINATOR_SLOTS;-const BUCKET_UPDATE_BY_INDEX = Uint8Array.from(- ATOMIC_BUCKETS.map((bucket) => {- const update = BUCKET_DEFINITIONS[bucket].update;- return update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2;- })-);+const STATIC_BUCKET_IDS = Object.fromEntries(STATIC_BUCKETS.map((bucket, index) => [bucket.path, index])) as Record<StaticDamageBucket, NumericBucketId>;+const DYNAMIC_FACTOR_TERMS = compileDamageTerms(DYNAMIC_BUCKETS);+const STATIC_FACTOR_TERMS = compileDamageTerms(STATIC_BUCKETS);+const DYNAMIC_EXPRESSIONS = {+ normal: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "normal" }),+ skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" })+};+const STATIC_EXPRESSIONS = {+ dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "dealer" }),+ taker: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "taker" })+};+const EVALUATED_EXPRESSION = compileDamageExpression(compileDamageTerms([EVALUATED_DAMAGE_BUCKET]), {});+const BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(DYNAMIC_BUCKETS);+const STATIC_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(STATIC_BUCKETS);+const EVALUATED_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates([EVALUATED_DAMAGE_BUCKET]);+const DAMAGE_SCALE_BUCKET = createEvaluatedDamageBucket(1 / 100);-export class DamageAggregationError extends Error {- readonly groupId: string;- readonly round: number;- readonly jobId: string;- readonly netPct: number | undefined;- readonly factor: number;- readonly contributors: DamageBucketTrace["contributors"];-- constructor(args: {- groupId: string;- round: number;- jobId: string;- netPct?: number;- factor: number;- contributors: DamageBucketTrace["contributors"];- }) {- super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor}${args.netPct === undefined ? "" : ` netPct=${args.netPct}`}`);- this.name = "DamageAggregationError";- this.groupId = args.groupId;- this.round = args.round;- this.jobId = args.jobId;- this.netPct = args.netPct;- this.factor = args.factor;- this.contributors = args.contributors;- }-}-export function calculateDamageJob(job: DamageJob,fighters: Record<SideId, ResolvedFighter>,- activeEffects: ActiveEffect[],- options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }+ options: { recorder: BattleRecorder; effectIndex: EffectIndex; staticDamageProfile: StaticDamageProfile; scratch?: DamageScratch; capToTakerTroops?: boolean; usedEffects?: ActiveEffect[] }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");- // The damage math is one path; `trace` only decides whether we also capture the (expensive)- // per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.- const traceEnabled = options.trace === true;- const recordAppliedEffects = traceEnabled || options.recordAppliedEffects === true;- const detail: DamageDetail = traceEnabled ? "full" : "fast";- const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);- const attacker = fighters[job.attackerSide];- const defender = fighters[job.defenderSide];- const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;- const defenderTroops = job.roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0;- const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));- const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));- const needsTraceBuckets = traceEnabled;- const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);- buckets.factors[TROOPS_COUNT_INDEX] = Math.max(0, armyTerm);- buckets.factors[SOURCE_EXTRA_SKILL_INDEX] = Math.max(0, job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);+ if (!options.recorder) throw new Error("calculateDamageJob requires a recorder");+ if (!options.staticDamageProfile) throw new Error("calculateDamageJob requires a staticDamageProfile");+ const recording = options.recorder.startDamageJob();+ const staticProfile = options.staticDamageProfile;+ const dealer = fighters[job.dealerSide];+ const taker = fighters[job.takerSide];+ const dealerTroops = job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0;+ const takerTroops = job.roundStartTroops[job.takerSide][job.takerUnit] ?? 0;+ const minInitialArmy = Math.max(1, Math.min(totalTroops(dealer.initialTroops), totalTroops(taker.initialTroops)));+ const armyTerm = Math.ceil(Math.sqrt(Math.max(0, dealerTroops)) * Math.sqrt(minInitialArmy));+ const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();+ applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);+ applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);- const appliedEffects: DamageEquationTrace["appliedEffects"] = [];- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];const usedEffects = options.usedEffects ?? [];applyBucketEffects(- damageEffectsForJob(options.effectIndex, job),+ options.effectIndex.damageGroupsByJobShape[damageJobSlot(job)],job.round,buckets,- detail,- recordAppliedEffects,- appliedEffects,- rejectedEffects,+ recording,usedEffects);- if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);- if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);-- const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, detail, staticProfile);+ const rawDamage = evaluateDamageExpressionForJob(job, buckets, staticProfile);const uncappedKills = Math.max(0, rawDamage);- const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);- const trace = traceEnabled- ? {- roundStartTroops: {- attacker: { ...job.roundStartTroops.attacker },- defender: { ...job.roundStartTroops.defender }- },- armyTerm,- atomicBuckets: toTraceBuckets(buckets, [- staticProfile.offense[job.attackerSide][job.attackerUnit],- staticProfile.defense[job.defenderSide][job.defenderUnit]- ]),- aggregationGroups,- appliedEffects,- rejectedEffects,- rawDamage,- finalKills: kills- }- : undefined;-- return {- kills,- appliedEffects: recordAppliedEffects ? appliedEffects : undefined,- trace- };+ const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);+ return recording.finish({ job, factors: buckets.factors, armyTerm, rawDamage, kills });}function applyBucketEffects(- effects: ActiveEffect[],+ groups: ActiveEffectGroup[],round: number,buckets: NumericDamageBuckets,- detail: DamageDetail,- recordAppliedEffects: boolean,- appliedEffects: DamageEquationTrace["appliedEffects"],- rejectedEffects: DamageEquationTrace["rejectedEffects"],+ recording: DamageJobRecorder,usedEffects: ActiveEffect[]): void {- let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;- for (const effect of effects) {- if (effect.expired) continue;- if (!advanceEffectAttackDelay(effect)) continue;- if (effect.sameEffectStacking === "max" && effect.stackingKey) {- maxGroups ??= new Map();- const key = `${effect.intent.type}:${effect.stackingKey}`;- const group = maxGroups.get(key);- if (group) {- group.effects.push(effect);- if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;- } else {- maxGroups.set(key, { selected: effect, effects: [effect] });+ for (const group of groups) {+ const effects = group.effects;+ if (effects.length === 0) continue;+ if (group.sameEffectStacking !== "max") {+ for (const effect of effects) {+ if (!advanceEffectAttackDelay(effect)) continue;+ applyBucketEffect(effect, round, buckets, recording, usedEffects);}- } else {- applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ continue;}+ if (effects.length === 1) {+ const effect = effects[0];+ if (advanceEffectAttackDelay(effect)) {+ applyBucketEffect(effect, round, buckets, recording, usedEffects);+ }+ continue;+ }+ let selected: ActiveEffect | undefined;+ const eligible: ActiveEffect[] = [];+ for (const effect of effects) {+ if (!advanceEffectAttackDelay(effect)) continue;+ eligible.push(effect);+ if (!selected || effect.getCurrentValuePct(round) > selected.getCurrentValuePct(round)) selected = effect;+ }+ if (selected) {+ applyBucketEffectGroup(selected, eligible, round, buckets, recording, usedEffects);+ }}- if (!maxGroups) return;- for (const group of maxGroups.values()) {- applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);- }}-// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the-// applied percentage. Shared by the single-candidate and max-group paths.+// Apply the selected candidate's value to its bucket and emit the observation to the recorder;+// returns the applied percentage. Shared by the single-candidate and max-group paths.function applySelectedBucket(selected: ActiveEffect,round: number,buckets: NumericDamageBuckets,- detail: DamageDetail,- recordAppliedEffects: boolean,- appliedEffects: DamageEquationTrace["appliedEffects"]+ recording: DamageJobRecorder): number {const appliedValuePct = selected.getCurrentValuePct(round);const bucketIndex = selected.bucketIndex;- const update = BUCKET_UPDATE_BY_INDEX[bucketIndex];- if (update === 0) buckets.factors[bucketIndex] = Math.max(0, appliedValuePct);- else if (update === 1) buckets.factors[bucketIndex] *= 1 + appliedValuePct / 100;- else buckets.factors[bucketIndex] += appliedValuePct / 100;- if (detail === "full") {- buckets.contributors?.[bucketIndex].push({- effectId: selected.source.effectId ?? selected.id,- source: sourceLabel(selected),- sourceSide: selected.ownerSide,- valuePct: appliedValuePct,- bucket: selected.intent.type,- stackingKey: selected.stackingKey,- sameEffectStacking: selected.sameEffectStacking- });- }- if (appliedValuePct !== 0 && recordAppliedEffects) {- const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {- kind: "modifier",- activeEffectId: selected.id,- effectId: selected.source.effectId ?? selected.id,- bucket: selected.intent.type,- valuePct: appliedValuePct,- source: sourceLabel(selected),- sourceSide: selected.ownerSide,- sameEffectStacking: selected.sameEffectStacking- };- if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;- appliedEffects.push(appliedEffect);- }+ applyBucketValue(buckets.factors, bucketIndex, appliedValuePct, BUCKET_UPDATE_BY_INDEX);+ recording.recordModifier(selected, appliedValuePct);return appliedValuePct;}// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]@@ -259,284 +180,161 @@function applyBucketEffect(effect: ActiveEffect,round: number,buckets: NumericDamageBuckets,- detail: DamageDetail,- recordAppliedEffects: boolean,- appliedEffects: DamageEquationTrace["appliedEffects"],- rejectedEffects: DamageEquationTrace["rejectedEffects"],+ recording: DamageJobRecorder,usedEffects: ActiveEffect[]): void {- const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(effect, round, buckets, recording);if (appliedValuePct !== 0) {usedEffects.push(effect);- } else if (detail === "full") {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });+ } else {+ recording.recordRejected(effect, "same_effect_max_superseded");}}function applyBucketEffectGroup(selected: ActiveEffect,effects: ActiveEffect[],round: number,buckets: NumericDamageBuckets,- detail: DamageDetail,- recordAppliedEffects: boolean,- appliedEffects: DamageEquationTrace["appliedEffects"],- rejectedEffects: DamageEquationTrace["rejectedEffects"],+ recording: DamageJobRecorder,usedEffects: ActiveEffect[]): void {- const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, round, buckets, recording);if (appliedValuePct !== 0) {// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.for (const effect of effects) {usedEffects.push(effect);- if (detail === "full" && effect !== selected) {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });- }+ if (effect !== selected) recording.recordRejected(effect, "same_effect_max_suppressed");}- } else if (detail === "full") {- for (const effect of effects) {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });- }+ } else {+ for (const effect of effects) recording.recordRejected(effect, "same_effect_max_superseded");}}-function factorTerm(bucket: AtomicBucket): DamageFactorTerm {- const definition = BUCKET_DEFINITIONS[bucket];- return {- id: bucket,- bucket: BUCKET_IDS[bucket],- bucketName: bucket,+function compileDamageTerms(definitions: readonly (BucketSpec | AggregateBucketSpec)[]): DamageFactorTerm[] {+ return definitions.map((definition, bucket) => ({+ bucket,+ role: "role" in definition ? definition.role : undefined,placement: definition.placement,appliesTo: definition.appliesTo- };+ }));}-function createNumericDamageBuckets(collectTrace: boolean): NumericDamageBuckets {- const factors = new Float64Array(ATOMIC_BUCKETS.length);- factors.fill(1);+function compileDamageExpression(+ terms: DamageFactorTerm[],+ selection: {+ role?: BucketRole;+ kind?: DamageJob["kind"];+ }+): CompiledDamageExpression {+ const selected = terms+ .filter((term) => selection.role === undefined || term.role === selection.role)+ .filter((term) => selection.kind === undefined || !term.appliesTo || term.appliesTo === selection.kind);return {- factors,- contributors: collectTrace ? Array.from({ length: ATOMIC_BUCKETS.length }, () => []) : undefined+ numeratorSlots: Int32Array.from(selected.filter((term) => term.placement === "numerator").map((term) => term.bucket)),+ denominatorSlots: Int32Array.from(selected.filter((term) => term.placement === "denominator").map((term) => term.bucket))};}-export function createFastDamageScratch(): DamageScratch {- return createNumericDamageBuckets(false);+function compileBucketUpdates(definitions: readonly DamageAggregationBucketSpec[]): Uint8Array {+ return Uint8Array.from(+ definitions.map(({ update, valueType }) =>+ valueType === "factor" ? 3 : update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2+ )+ );}-function resetDamageScratch(buckets: DamageScratch): DamageScratch {- buckets.factors.fill(1);- return buckets;+function applyBucketValue(factors: Float64Array, bucket: number, value: number, updates: Uint8Array): void {+ const update = updates[bucket];+ if (update === 0) factors[bucket] = rawBucketFactor(value);+ else if (update === 1) factors[bucket] *= pctBucketFactor(value);+ else if (update === 2) factors[bucket] += pctBucketDelta(value);+ else factors[bucket] = value;}-function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {- if (!bucket.startsWith("passive.") || !term) continue;- for (const contributor of term.contributors) {- appliedEffects.push({- kind: "modifier",- activeEffectId: contributor.effectId,- effectId: contributor.effectId,- bucket,- valuePct: contributor.valuePct,- source: contributor.source,- sourceSide: contributor.sourceSide,- stackingKey: contributor.stackingKey,- sameEffectStacking: contributor.sameEffectStacking- });- }- }+function applyDynamicDamageBucketValue(buckets: NumericDamageBuckets, bucket: AtomicBucket, value: number): number {+ const bucketIndex = BUCKET_IDS[bucket];+ applyBucketValue(buckets.factors, bucketIndex, value, BUCKET_UPDATE_BY_INDEX);+ return buckets.factors[bucketIndex];}-function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {- if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);- const factors = buckets.factors;- let numerator = 1;- for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];- let denominator = 1;- for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];- return {- rawDamage: numerator / denominator,- aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS- };+function multiplySlots(initial: number, factors: Float64Array, slots: Int32Array): number {+ let product = initial;+ for (const slot of slots) product *= factors[slot];+ return product;}-function evaluateProfiledDamageExpression(- job: DamageJob,- buckets: NumericDamageBuckets,- detail: DamageDetail,- staticProfile: StaticDamageProfile-): DamageExpressionResult {- const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];- const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];- if (!offense.playerFactorsValid || !defense.playerFactorsValid) validateProfiledStaticFactors(job, staticProfile);- const factors = buckets.factors;- let numerator =- factors[TROOPS_COUNT_INDEX] *- factors[SOURCE_EXTRA_SKILL_INDEX] *- offense.factor *- defense.factor;- for (const slot of PROFILED_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];- let denominator = 100;- for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];- return {- rawDamage: numerator / denominator,- aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS- };+function createNumericDamageBuckets(): NumericDamageBuckets {+ const factors = new Float64Array(ATOMIC_BUCKETS.length);+ factors.fill(1);+ return { factors };}-function validateProfiledStaticFactors(job: DamageJob, staticProfile: StaticDamageProfile): void {- validateProfiledPctFactor(job, "player.attacker.attack", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.attack");- validateProfiledPctFactor(job, "player.attacker.lethality", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.lethality");- validateProfiledPctFactor(job, "player.defender.health", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.health");- validateProfiledPctFactor(job, "player.defender.defense", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.defense");+export function createDamageScratch(): DamageScratch {+ return createNumericDamageBuckets();}-function validateProfiledPctFactor(job: DamageJob, groupId: string, entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): void {- const term = entry.buckets[bucket];- const totalPct = term?.totalPct ?? 0;- const factor = 1 + totalPct / 100;- if (factor > 0) return;- throw new DamageAggregationError({- groupId,- round: job.round,- jobId: job.id,- netPct: totalPct,- factor,- contributors: term?.contributors ?? []- });+/** Creates neutral factors for the closed/static bucket pool. */+export function createStaticDamageBucketFactors(): StaticDamageBucketFactors {+ const factors = new Float64Array(STATIC_BUCKETS.length);+ factors.fill(1);+ return factors;}-function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {- const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};- if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);- for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {- if (term.appliesTo && term.appliesTo !== job.kind) continue;- const definition = BUCKET_DEFINITIONS[term.bucketName];- const factor = buckets.factors[term.bucket];- if (definition.valueType === "raw") {- aggregationGroups[term.id] = {- id: term.id,- mode: "raw",- placement: term.placement,- inputBuckets: [term.bucketName],- factor,- contributors: contributorsForTerm(term, buckets)- };- } else {- const totalPct = pctFromFactor(factor);- aggregationGroups[term.id] = {- id: term.id,- mode: "sum_pct",- placement: term.placement,- inputBuckets: [term.bucketName],- totalPct,- factor,- contributors: contributorsForTerm(term, buckets)- };- }- }- return aggregationGroups;+/** Populates a static bucket with exactly the same metadata-driven update rule as a live bucket. */+export function applyStaticDamageBucketValue(+ factors: StaticDamageBucketFactors,+ bucket: StaticDamageBucket,+ value: number+): number {+ const bucketIndex = STATIC_BUCKET_IDS[bucket];+ applyBucketValue(factors, bucketIndex, value, STATIC_BUCKET_UPDATE_BY_INDEX);+ return factors[bucketIndex];}-function addStaticAggregationGroups(- aggregationGroups: Record<string, DamageAggregationGroupTrace>,- job: DamageJob,- staticProfile: StaticDamageProfile-): void {- const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];- const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];- addStaticRawGroup(aggregationGroups, "troops.baseAttack", "numerator", offense, "troops.baseAttack");- addStaticRawGroup(aggregationGroups, "troops.baseLethality", "numerator", offense, "troops.baseLethality");- addStaticPctGroup(aggregationGroups, "player.attacker.attack", "numerator", offense, "player.attack");- addStaticPctGroup(aggregationGroups, "player.attacker.lethality", "numerator", offense, "player.lethality");- addStaticPctGroup(aggregationGroups, "passive.attacker.attack.up", "numerator", offense, "passive.attack.up");- addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.up", "numerator", offense, "passive.lethality.up");- addStaticPctGroup(aggregationGroups, "passive.defender.health.down", "numerator", defense, "passive.health.down");- addStaticPctGroup(aggregationGroups, "passive.defender.defense.down", "numerator", defense, "passive.defense.down");-- addStaticRawGroup(aggregationGroups, "troops.baseHealth", "denominator", defense, "troops.baseHealth");- addStaticRawGroup(aggregationGroups, "troops.baseDefense", "denominator", defense, "troops.baseDefense");- addStaticPctGroup(aggregationGroups, "player.defender.health", "denominator", defense, "player.health");- addStaticPctGroup(aggregationGroups, "player.defender.defense", "denominator", defense, "player.defense");- addStaticPctGroup(aggregationGroups, "passive.attacker.attack.down", "denominator", offense, "passive.attack.down");- addStaticPctGroup(aggregationGroups, "passive.attacker.lethality.down", "denominator", offense, "passive.lethality.down");- addStaticPctGroup(aggregationGroups, "passive.defender.health.up", "denominator", defense, "passive.health.up");- addStaticPctGroup(aggregationGroups, "passive.defender.defense.up", "denominator", defense, "passive.defense.up");+/** Selects one role's static buckets as an ordinary input to the standard evaluator. */+export function staticDamageBucketSet(factors: StaticDamageBucketFactors, role: BucketRole): DamageBucketSet {+ return { factors, expression: STATIC_EXPRESSIONS[role] };}-function addStaticRawGroup(- aggregationGroups: Record<string, DamageAggregationGroupTrace>,- id: string,- placement: GroupPlacement,- entry: StaticDamageProfileEntry,- bucket: StaticDamageBucket-): void {- const term = entry.buckets[bucket];- aggregationGroups[id] = {- id,- mode: "raw",- placement,- inputBuckets: [bucket],- factor: Math.max(0, term?.raw ?? 0),- contributors: term?.contributors ?? []- };+function resetDamageScratch(buckets: DamageScratch): DamageScratch {+ buckets.factors.fill(1);+ return buckets;}-function addStaticPctGroup(- aggregationGroups: Record<string, DamageAggregationGroupTrace>,- id: string,- placement: GroupPlacement,- entry: StaticDamageProfileEntry,- bucket: StaticDamageBucket-): void {- const term = entry.buckets[bucket];- const totalPct = term?.totalPct ?? 0;- aggregationGroups[id] = {- id,- mode: "sum_pct",- placement,- inputBuckets: [bucket],- totalPct,- factor: 1 + totalPct / 100,- contributors: term?.contributors ?? []- };+/** Pure, composable reduction of bucket sets to an ordinary one-value bucket set. */+export function evaluateDamageExpression(...inputs: DamageBucketSet[]): DamageBucketSet {+ let factor = 1;+ for (const input of inputs) {+ const numerator = multiplySlots(1, input.factors, input.expression.numeratorSlots);+ const denominator = multiplySlots(1, input.factors, input.expression.denominatorSlots);+ factor *= numerator / denominator;+ }+ return createEvaluatedDamageBucket(factor);}-function contributorsForTerm(term: DamageFactorTerm, buckets: NumericDamageBuckets): DamageBucketTrace["contributors"] {- if (!buckets.contributors) return [];- return buckets.contributors[term.bucket] ?? [];+function createEvaluatedDamageBucket(factor: number): DamageBucketSet {+ const factors = new Float64Array(1);+ applyBucketValue(factors, 0, factor, EVALUATED_BUCKET_UPDATE_BY_INDEX);+ return { factors, expression: EVALUATED_EXPRESSION };}-function toTraceBuckets(buckets: NumericDamageBuckets, staticEntries: StaticDamageProfileEntry[] = []): AtomicBuckets {- const traced = Object.fromEntries(- ATOMIC_BUCKETS.map((bucket, index) => {- const contributors = buckets.contributors?.[index] ?? [];- const definition = BUCKET_DEFINITIONS[bucket];- const factor = buckets.factors[index];- if (definition.valueType === "raw") {- return [bucket, { raw: factor, factor, contributors: [...contributors] }];- }- return [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...contributors] }];- })- ) as AtomicBuckets;- for (const entry of staticEntries) {- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {- if (!term) continue;- traced[bucket] =- term.raw !== undefined- ? { raw: term.raw, factor: Math.max(0, term.raw), contributors: [...term.contributors] }- : { totalPct: term.totalPct ?? 0, factor: 1 + (term.totalPct ?? 0) / 100, contributors: [...term.contributors] };- }- }- return traced;+function evaluateDamageExpressionForJob(+ job: DamageJob,+ buckets: NumericDamageBuckets,+ staticProfile: StaticDamageProfile+): number {+ const dealerFactor = staticProfile[job.dealerSide][job.dealerUnit].dealerFactor;+ const takerFactor = staticProfile[job.takerSide][job.takerUnit].takerFactor;+ return evaluateDamageExpression(+ { factors: buckets.factors, expression: DYNAMIC_EXPRESSIONS[job.kind] },+ dealerFactor,+ takerFactor,+ DAMAGE_SCALE_BUCKET+ ).factors[0];}function totalTroops(troops: Record<UnitType, number>): number {return UNIT_TYPES.reduce((sum, unit) => sum + (troops[unit] ?? 0), 0);}--function pctFromFactor(factor: number): number {- return Number(((factor - 1) * 100).toFixed(12));-}Index: simulator/src/damageBuckets.test.ts===================================================================--- simulator/src/damageBuckets.test.ts prev run+++ simulator/src/damageBuckets.test.ts this run@@ -1,8 +1,8 @@import assert from "node:assert/strict";import { test } from "node:test";-import { createFastDamageScratch } from "./damage";+import { createDamageScratch } from "./damage";import { ATOMIC_BUCKETS, bucketDefinition } from "./damageBuckets";test("active hero and troop damage aliases are supported all-damage buckets", () => {for (const bucket of [@@ -13,9 +13,9 @@"active.troop.damage.up","active.troop.damage.down","active.troop.damageTaken.up","active.troop.damageTaken.down"- ]) {+ ] as const) {assert.equal(ATOMIC_BUCKETS.includes(bucket), true, bucket);assert.equal(bucketDefinition(bucket)?.appliesTo, undefined, bucket);}assert.equal(bucketDefinition("active.hero.damage.up")?.placement, "numerator");@@ -33,9 +33,9 @@assert.equal(bucketDefinition("type.all.damage.down")?.appliesTo, undefined);});test("damage scratch stores one factor value per atomic bucket", () => {- const scratch = createFastDamageScratch() as unknown as {+ const scratch = createDamageScratch() as unknown as {factors?: Float64Array;raw?: unknown;pct?: unknown;rawSet?: unknown;Index: simulator/src/damageBuckets.ts===================================================================--- simulator/src/damageBuckets.ts prev run+++ simulator/src/damageBuckets.ts this run@@ -1,188 +1,164 @@import type { DamageKind } from "./types";-export type BucketRole = "attacker" | "defender";-export type BucketValueType = "raw" | "pct";+export type BucketRole = "dealer" | "taker";+export type BucketValueType = "raw" | "pct" | "factor";export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor";export type BucketPlacement = "numerator" | "denominator";-// "static" pools are closed at compile time (base/player/passive): every contributor is-// known at battle start, so they are aggregated once by the static damage profile.-// "dynamic" pools are open: triggered/timed/conditional effects can feed them per job,-// so they are aggregated in the per-job runtime scratch.-export type BucketPhase = "static" | "dynamic";+type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source" | "aggregate";-export interface BucketLeaf {- role: BucketRole;+export interface DamageAggregationBucketSpec {+ path: string;valueType: BucketValueType;update: BucketUpdate;placement: BucketPlacement;appliesTo?: DamageKind;}-type BucketTree = {- [key: string]: BucketLeaf | BucketTree;-};+export interface BucketSpec extends DamageAggregationBucketSpec {+ family: Exclude<BucketFamily, "aggregate">;+ role: BucketRole;+}-export const BUCKETS = {- troops: {- count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },- baseAttack: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },- baseLethality: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },- baseHealth: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },- baseDefense: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" }- },- player: {- attack: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- lethality: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- health: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- defense: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- passive: {- attack: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- lethality: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- health: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }- },- defense: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }- }- },- active: {- hero: {- attack: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- defense: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }- },- lethality: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- health: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }- },- damage: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- damageTaken: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- }- },- troop: {- attack: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- defense: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }- },- lethality: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- health: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }- },- damage: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- },- damageTaken: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }- }- }- },- type: {- all: {- damage: {- up: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },- down: { role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" }- }- },- normal: {- damage: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" }- },- damageTaken: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" }- }- },- skill: {- damage: {- up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },- down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" }- },- damageTaken: {- up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },- down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" }- }- }- },- source: {- extraSkill: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" }- }-} as const satisfies BucketTree;--export interface BucketDefinition extends BucketLeaf {- path: string;- phase: BucketPhase;+export interface AggregateBucketSpec extends DamageAggregationBucketSpec {+ family: "aggregate";}-// A pool is closed at compile time (static phase) when no triggered/timed/conditional-// effect can feed it: base troop stats, player stat bonuses, and battle-start passives.-// Everything else is an open pool aggregated per damage job.-const STATIC_BUCKET_FAMILIES = ["troops.base", "player.", "passive."];+// The result of evaluating any bucket set is represented as this ordinary one-value bucket.+// It has no dealer/taker role because its input set has already resolved that distinction.+export const EVALUATED_DAMAGE_BUCKET = {+ path: "aggregate.factor",+ family: "aggregate",+ valueType: "factor",+ update: "assign_factor",+ placement: "numerator"+} as const satisfies AggregateBucketSpec;-function phaseForPath(path: string): BucketPhase {- return STATIC_BUCKET_FAMILIES.some((family) => path.startsWith(family)) ? "static" : "dynamic";-}+// Closed pools: every contributor is known while preparing the battle, so these+// buckets are aggregated once into StaticDamageProfile factors.+export const STATIC_BUCKETS = [+ { path: "troops.baseAttack", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },+ { path: "troops.baseLethality", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },+ { path: "troops.baseHealth", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },+ { path: "troops.baseDefense", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },+ { path: "player.attack", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "player.lethality", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "player.health", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "player.defense", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.attack.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "passive.attack.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.lethality.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "passive.lethality.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.health.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.health.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "passive.defense.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.defense.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+] as const satisfies readonly BucketSpec[];-export const BUCKET_DEFINITIONS = flattenBuckets(BUCKETS);-// ATOMIC_BUCKETS is the runtime (open-pool) bucket set used by the per-job scratch and-// damage expression. Static-phase buckets are aggregated by the static damage profile and-// never enter the runtime scratch, so they are intentionally excluded here.-export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)- .filter((path) => BUCKET_DEFINITIONS[path].phase === "dynamic")- .sort();-export const STATIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)- .filter((path) => BUCKET_DEFINITIONS[path].phase === "static")- .sort();+// Open pools: runtime effects can feed these buckets while the battle is running.+// Array order is the numeric slot order used by the fast damage scratch.+export const DYNAMIC_BUCKETS = [+ { path: "active.hero.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "source.extraSkill", family: "source", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" },+ { path: "troops.count", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },+ { path: "type.all.damage.down", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" },+ { path: "type.all.damage.up", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },+ { path: "type.normal.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },+ { path: "type.normal.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },+ { path: "type.normal.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },+ { path: "type.normal.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },+ { path: "type.skill.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },+ { path: "type.skill.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },+ { path: "type.skill.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },+ { path: "type.skill.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }+] as const satisfies readonly BucketSpec[];-export type AtomicBucket = string;+export type StaticBucketSpec = (typeof STATIC_BUCKETS)[number];+export type DynamicBucketSpec = (typeof DYNAMIC_BUCKETS)[number];+export type StaticDamageBucket = StaticBucketSpec["path"];+export type AtomicBucket = DynamicBucketSpec["path"];export type BucketName = AtomicBucket;+export type StaticRawBucket = Extract<StaticBucketSpec, { valueType: "raw" }>["path"];+export type StaticPlayerBucket = Extract<StaticBucketSpec, { family: "player" }>["path"];+export type PassiveBucket = Extract<StaticBucketSpec, { family: "passive" }>["path"];+export type BucketPath = StaticDamageBucket | AtomicBucket;+type NativeBucketSpec = StaticBucketSpec | DynamicBucketSpec;+type BucketDefinitionFields = {+ family: NativeBucketSpec["family"];+ role: NativeBucketSpec["role"];+ valueType: NativeBucketSpec["valueType"];+ update: NativeBucketSpec["update"];+ placement: NativeBucketSpec["placement"];+ appliesTo?: DamageKind;+};+export type BucketDefinition =+ | (BucketDefinitionFields & { path: StaticDamageBucket; phase: "static" })+ | (BucketDefinitionFields & { path: AtomicBucket; phase: "dynamic" });++export const ATOMIC_BUCKETS = DYNAMIC_BUCKETS.map((bucket) => bucket.path) as AtomicBucket[];+export const DYNAMIC_EFFECT_BUCKETS = DYNAMIC_BUCKETS+ .filter((bucket) => bucket.family === "active" || bucket.family === "type")+ .map((bucket) => bucket.path) as AtomicBucket[];+export const PASSIVE_BUCKETS = STATIC_BUCKETS+ .filter((bucket): bucket is Extract<StaticBucketSpec, { family: "passive" }> => bucket.family === "passive")+ .map((bucket) => bucket.path) as PassiveBucket[];++const ALL_BUCKET_DEFINITIONS: BucketDefinition[] = [+ ...STATIC_BUCKETS.map((bucket) => ({ ...bucket, phase: "static" as const })),+ ...DYNAMIC_BUCKETS.map((bucket) => ({ ...bucket, phase: "dynamic" as const }))+];++export const BUCKET_DEFINITIONS = Object.fromEntries(+ ALL_BUCKET_DEFINITIONS.map((definition) => [definition.path, definition])+) as Record<BucketPath, BucketDefinition>;+export function bucketDefinition(path: string): BucketDefinition | undefined {- return BUCKET_DEFINITIONS[path];+ return BUCKET_DEFINITIONS[path as BucketPath];}-function flattenBuckets(tree: BucketTree, prefix = ""): Record<string, BucketDefinition> {- const buckets: Record<string, BucketDefinition> = {};- for (const [key, value] of Object.entries(tree)) {- const path = prefix ? `${prefix}.${key}` : key;- if (isBucketLeaf(value)) buckets[path] = { ...value, path, phase: phaseForPath(path) };- else Object.assign(buckets, flattenBuckets(value, path));- }- return buckets;+export function isPassiveBucket(bucket: string): bucket is PassiveBucket {+ const definition = BUCKET_DEFINITIONS[bucket as PassiveBucket];+ return definition?.phase === "static" && definition.family === "passive";}-function isBucketLeaf(value: BucketLeaf | BucketTree): value is BucketLeaf {- return "role" in value && "valueType" in value && "update" in value && "placement" in value;+export function passiveBucketRole(bucket: string): BucketRole | undefined {+ return isPassiveBucket(bucket) ? BUCKET_DEFINITIONS[bucket].role : undefined;}++// The one and only bucket-value conversion. A raw bucket contributes its value clamped+// non-negative; a pct bucket contributes its percentage delta or the corresponding factor.+// Every consumer (static profile, dynamic scratch, recorders) must use these.+export function rawBucketFactor(raw: number): number {+ return Math.max(0, raw);+}++export function pctBucketDelta(totalPct: number): number {+ return totalPct / 100;+}++export function pctBucketFactor(totalPct: number): number {+ return 1 + pctBucketDelta(totalPct);+}Index: simulator/src/effectIndex.test.ts===================================================================--- simulator/src/effectIndex.test.ts prev run+++ simulator/src/effectIndex.test.ts this run@@ -1,13 +1,13 @@import assert from "node:assert/strict";import { test } from "node:test";-import { cloneEffectIndex, createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";+import { createEffectIndex, damageJobSlot, damageShapeSlotsForEffect, expireEffectIndex, indexEffect } from "./effectIndex";+import { resolvedEffectScopeKey } from "./effects";import { unitMask } from "./types";import type { ActiveEffect, DamageJob } from "./types";test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {- const index = createEffectIndex();const effect: ActiveEffect = {id: "effect-1",source: { kind: "hero_skill", side: "attacker", effectId: "boost" },intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },@@ -25,8 +25,9 @@uses: 0,stackingKey: "stack",sameEffectStacking: "add"};+ const index = preparedIndex([effect]);indexEffect(index, effect);const job: DamageJob = {id: "job-1",@@ -36,44 +37,40 @@roundStartTroops: {attacker: { infantry: 100, lancer: 0, marksman: 0 },defender: { infantry: 0, lancer: 100, marksman: 0 }},- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer"+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer"};- assert.deepEqual(damageEffectsForJob(index, job), [effect]);+ assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job)].flatMap((group) => group.effects), [effect]);});-test("effect index excludes static-profile bucket effects", () => {- const index = createEffectIndex();+test("static-profile bucket effects are not prepared into the runtime effect index", () => {const passive = effect("passive.attack.up");const active = effect("active.hero.lethality.up");- indexEffect(index, passive);+ const index = preparedIndex([passive, active]);indexEffect(index, active);- assert.deepEqual(damageEffectsForJob(index, job()), [active]);+ assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job())].flatMap((group) => group.effects), [active]);});-test("cloned effect index remaps shared effects while owning its bucket arrays", () => {- const index = createEffectIndex();- const original = effect("active.hero.lethality.up");- const replacement = { ...original };- indexEffect(index, original);+test("expiring a modifier swap-removes it once from its shared job-shape group", () => {+ const first = effect("active.hero.lethality.up");+ const second = { ...effect("active.hero.lethality.up"), id: "second" };+ const index = preparedIndex([first, second]);+ indexEffect(index, first);+ indexEffect(index, second);- const clone = cloneEffectIndex(index, (candidate) => {- assert.equal(candidate, original);- return replacement;- });- const originalCandidates = damageEffectsForJob(index, job());- const clonedCandidates = damageEffectsForJob(clone, job());+ const group = index.damageGroupsByJobShape[damageJobSlot(job())][0];+ assert.deepEqual(group.effects, [first, second]);+ expireEffectIndex(index, first);- assert.notEqual(clonedCandidates, originalCandidates);- assert.deepEqual(clonedCandidates, [replacement]);- clonedCandidates.push(effect("active.hero.attack.up"));- assert.deepEqual(originalCandidates, [original]);+ assert.deepEqual(group.effects, [second]);+ assert.equal(second.damageIndexPosition, 0);+ assert.equal(first.damageIndexGroup, undefined);});function effect(type: string): ActiveEffect {return {@@ -96,8 +93,32 @@sameEffectStacking: "add"};}+function preparedIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {+ const groups: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];+ const byShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][] = Array.from({ length: 72 }, () => []);+ const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["damageIndexGroup"]>>();+ for (const effect of effects) {+ const slots = damageShapeSlotsForEffect(effect);+ if (slots.length === 0) continue;+ const key = `${effect.stackingKey ?? effect.id}:${resolvedEffectScopeKey(effect.appliesTo, effect.appliesVs)}`;+ let group = byResolvedGroup.get(key);+ if (!group) {+ group = {+ effects: [],+ bucketIndex: 0,+ sameEffectStacking: effect.sameEffectStacking+ };+ byResolvedGroup.set(key, group);+ groups.push(group);+ for (const slot of slots) byShape[slot].push(group);+ }+ effect.damageIndexGroup = group;+ }+ return createEffectIndex(groups, byShape);+}+function job(): DamageJob {return {id: "job-1",round: 1,@@ -106,10 +127,10 @@roundStartTroops: {attacker: { infantry: 100, lancer: 0, marksman: 0 },defender: { infantry: 0, lancer: 100, marksman: 0 }},- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer"+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer"};}Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -1,34 +1,54 @@-import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";+import type { ActiveEffect, ActiveEffectGroup, DamageJob, DamageKind, SideId, UnitType } from "./types";import { unitsFromMask } from "./types";import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";export interface EffectIndex {- damageByJobShape: Array<ActiveEffect[] | undefined>;+ // Prepared stable graph. Repeated synchronous runs reuse these group references and+ // reset only their live activation arrays.+ damageGroupsByJobShape: ActiveEffectGroup[][];+ damageGroups: ActiveEffectGroup[];controls: ActiveEffect[];extraAttacks: ActiveEffect[];battleOrder: ActiveEffect[];}export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;-const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));-export function createEffectIndex(): EffectIndex {+export function createEffectIndex(+ damageGroups: ActiveEffectGroup[] = [],+ damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => [])+): EffectIndex {+ for (const group of damageGroups) group.effects.length = 0;return {- damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),+ damageGroupsByJobShape,+ damageGroups,controls: [],extraAttacks: [],battleOrder: []};}-export function cloneEffectIndex(index: EffectIndex, cloneEffect: (effect: ActiveEffect) => ActiveEffect): EffectIndex {- return {- damageByJobShape: index.damageByJobShape.map((effects) => effects?.map(cloneEffect)),+export function cloneEffectIndex(+ index: EffectIndex,+ preparedEffects: ActiveEffect[],+ cloneEffect: (effect: ActiveEffect) => ActiveEffect+): EffectIndex {+ for (const group of index.damageGroups) group.effects.length = 0;+ const clone: EffectIndex = {+ damageGroupsByJobShape: index.damageGroupsByJobShape,+ damageGroups: index.damageGroups,controls: index.controls.map(cloneEffect),extraAttacks: index.extraAttacks.map(cloneEffect),battleOrder: index.battleOrder.map(cloneEffect)};+ for (const effect of preparedEffects) {+ const group = effect.damageIndexGroup;+ if (!group || effect.damageIndexPosition === undefined) continue;+ effect.damageIndexPosition = group.effects.length;+ group.effects.push(effect);+ }+ return clone;}export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {if (effect.kind === "control") {@@ -43,19 +63,13 @@index.battleOrder.push(effect);return;}- const definition = bucketDefinition(effect.intent.type);- // Static-phase buckets (passive.*) are aggregated by the static damage profile, not the- // per-job runtime path, so they must not enter the damage job-shape index.- if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;- effect.bucketIndex = BUCKET_INDEX.get(definition.path) ?? -1;-- for (const slot of shapeSlotsFor(effect, definition.path)) {- const arr = index.damageByJobShape[slot];- if (arr) arr.push(effect);- else index.damageByJobShape[slot] = [effect];- }+ const group = effect.damageIndexGroup;+ if (!group) throw new Error(`Runtime modifier ${effect.id} has no prepared damage group`);+ effect.bucketIndex = group.bucketIndex;+ effect.damageIndexPosition = group.effects.length;+ group.effects.push(effect);}export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;@@ -67,45 +81,60 @@effect.expired = true;if (effect.kind === "control") removeStable(index.controls, effect);else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);+ else removeDamageEffect(effect);}-export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {- return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];+export function damageJobSlot(job: DamageJob): number {+ return damageJobShapeSlot(job.kind, job.dealerSide, job.dealerUnit, job.takerSide, job.takerUnit);}export function damageJobShapeSlot(jobKind: DamageKind,- attackerSide: SideId,- attackerUnit: UnitType,- defenderSide: SideId,- defenderUnit: UnitType+ dealerSide: SideId,+ dealerUnit: UnitType,+ takerSide: SideId,+ takerUnit: UnitType): number {- return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);+ return (((kindIndex(jobKind) * 2 + sideIndex(dealerSide)) * 3 + unitIndex(dealerUnit)) * 2 + sideIndex(takerSide)) * 3 + unitIndex(takerUnit);}-const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();-function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+const JOB_SHAPE_CACHE = new Map<number, Uint8Array>();+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));++export function damageBucketIndex(bucket: AtomicBucket): number {+ return BUCKET_INDEX.get(bucket) ?? -1;+}++export function damageShapeSlotsForEffect(effect: ActiveEffect, bucketOverride?: AtomicBucket): Uint8Array {+ const runtimeDefinition = bucketOverride === undefined ? bucketDefinition(effect.intent.type) : undefined;+ if (!bucketOverride && (!runtimeDefinition || runtimeDefinition.valueType !== "pct" || runtimeDefinition.phase === "static")) {+ return EMPTY_JOB_SHAPE_SLOTS;+ }+ const bucket = (bucketOverride ?? runtimeDefinition?.path) as AtomicBucket | undefined;+ if (!bucket) return EMPTY_JOB_SHAPE_SLOTS;const key =((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 +(effect.appliesVs.units & 7);- const cached = SHAPE_SLOTS_CACHE.get(key);+ const cached = JOB_SHAPE_CACHE.get(key);if (cached) return cached;const slots = buildShapeSlots(effect, bucket);- SHAPE_SLOTS_CACHE.set(key, slots);+ JOB_SHAPE_CACHE.set(key, slots);return slots;}+const EMPTY_JOB_SHAPE_SLOTS = new Uint8Array();+function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {const definition = bucketDefinition(bucket)!;const slots: number[] = [];const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];for (const jobKind of jobKinds) {for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {slots.push(- definition.role === "attacker"+ definition.role === "dealer"? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit): damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit));}@@ -125,4 +154,19 @@function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {const index = effects.indexOf(effect);if (index >= 0) effects.splice(index, 1);}++function removeDamageEffect(effect: ActiveEffect): void {+ const group = effect.damageIndexGroup;+ const position = effect.damageIndexPosition;+ if (!group || position === undefined) return;+ const lastPosition = group.effects.length - 1;+ const moved = group.effects[lastPosition];+ if (position !== lastPosition) {+ group.effects[position] = moved;+ moved.damageIndexPosition = position;+ }+ group.effects.pop();+ effect.damageIndexGroup = undefined;+ effect.damageIndexPosition = undefined;+}Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -4,8 +4,9 @@AttackIntent,EffectDuration,EffectIntentDefinition,EvolvingActiveEffect,+ ResolvedEffectIntentDefinition,ResolvedSkill,ResolvedUnitScope,SameEffectStacking,SideId,@@ -55,10 +56,10 @@if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every, trigger.first)) return false;if (!intent) return true;const selectors = compiledTriggerForSkill(skill);return (- triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&- triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit)+ triggerScopeMatches(selectors.source, intent.dealerSide, intent.dealerUnit) &&+ triggerScopeMatches(selectors.target, intent.takerSide, intent.takerUnit));}export function compiledTriggerForSkill(skill: ResolvedSkill): NonNullable<ResolvedSkill["compiledTrigger"]> {@@ -110,9 +111,9 @@if (previous < first) return true;return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);}-function compiledActivation(skill: ResolvedSkill, intent: EffectIntentDefinition): CompiledActivation {+function compiledActivation(skill: ResolvedSkill, intent: ResolvedEffectIntentDefinition): CompiledActivation {const cached = ACTIVATION_CACHE.get(intent);if (cached) return cached;const units = intent.units ?? {};const ownerSide = skill.side;@@ -140,15 +141,9 @@ownerSide,kind: effectKind,initialValuePct: finiteNumberOrZero(intent.value),getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,- valueEvolution: evolution- ? {- type: evolution.type,- step: evolution.step,- amount: finiteNumberOrZero(evolution.value)- }- : undefined,+ valueEvolution: evolution ? { type: evolution.type, step: evolution.step, amount: finiteNumberOrZero(evolution.value) } : undefined,triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,duration,turnDelay: Math.max(0, duration.turns?.delay ?? 0),attackDelay: duration.attacks?.delay ?? 0,@@ -163,9 +158,9 @@ACTIVATION_CACHE.set(intent, compiled);return compiled;}-export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+export function activateEffect(skill: ResolvedSkill, intent: ResolvedEffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {const compiled = compiledActivation(skill, intent);let appliesTo = compiled.staticAppliesTo;let appliesVs = compiled.staticAppliesVs;if (compiled.intentScoped && attackIntent) {@@ -191,14 +186,28 @@duration: compiled.duration,remainingAttackDelay: compiled.attackDelay,uses: 0,stackingKey: compiled.stackingKey,- sameEffectStacking: compiled.sameEffectStacking+ sameEffectStacking: compiled.sameEffectStacking,+ damageIndexGroup: compiled.kind === "modifier"+ ? intent.damageGroup ?? intent.damageGroupsByScopeKey?.[resolvedEffectScopeKey(appliesTo, appliesVs)]+ : undefined,+ damageIndexPosition: undefined};if (!compiled.valueEvolution) return effect;return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;}+// Flatten both resolved usage scopes into a compact preparation-time lookup key.+// Unit masks occupy three bits, so the full key space is 2 * 8 * 2 * 8 = 256.+export function resolvedEffectScopeKey(appliesTo: ResolvedUnitScope, appliesVs: ResolvedUnitScope): number {+ return ((sideIndex(appliesTo.side) * 8 + appliesTo.units) * 2 + sideIndex(appliesVs.side)) * 8 + appliesVs.units;+}++function sideIndex(side: SideId): number {+ return side === "attacker" ? 0 : 1;+}+function skillActivationSourceKey(skill: ResolvedSkill): string {return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";}@@ -305,12 +314,12 @@if (role === "applies_vs" && value === "all") {throw new Error('effect units.applies_vs cannot be "all"; use "any" for an unrestricted usage gate');}if ((value === "trigger.source" || value === "trigger") && attackIntent) {- return { side: attackIntent.attackerSide, units: unitMask(attackIntent.attackerUnit) };+ return { side: attackIntent.dealerSide, units: unitMask(attackIntent.dealerUnit) };}if ((value === "trigger.target" || value === "target") && attackIntent) {- return { side: attackIntent.defenderSide, units: unitMask(attackIntent.defenderUnit) };+ return { side: attackIntent.takerSide, units: unitMask(attackIntent.takerUnit) };}if (ownerSide && isRelationQualifiedSelector(value)) {const selector = parseTriggerSelector(value, "self");const side = sideForTriggerRelation(ownerSide, selector.relation);Index: simulator/src/gen8Heroes.test.ts===================================================================--- simulator/src/gen8Heroes.test.ts prev run+++ simulator/src/gen8Heroes.test.ts this run@@ -0,0 +1,89 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { loadSimulatorConfig } from "./config";+import { resolveFighter } from "./resolve";++test("Gen8 heroes are registered with the shared S8 expedition stats", () => {+ const config = loadSimulatorConfig();++ assert.deepEqual(config.heroGenerationStats.S8, {+ attack: 780.62,+ defense: 780.62,+ lethality: 193,+ health: 193+ });+ assert.deepEqual(+ ["Gatot", "Sonya", "Hendrik"].map((name) => [name, config.heroDefinitions[name]?.hero_generation]),+ [+ ["Gatot", "S8"],+ ["Sonya", "S8"],+ ["Hendrik", "S8"]+ ]+ );+});++test("Gen8 widget skills are fourth, passive, and engagement-gated", () => {+ const config = loadSimulatorConfig();+ const expectations = [+ ["Gatot", "IndestructibleCity", "garrison", "passive.defense.up"],+ ["Sonya", "VortexTurret", "garrison", "passive.lethality.up"],+ ["Hendrik", "AbyssalBlessing", "rally", "passive.attack.up"]+ ] as const;++ for (const [heroName, skillName, engagement, effectType] of expectations) {+ const definition = config.heroDefinitions[heroName];+ const skillEntries = Object.entries(definition.skills);+ assert.equal(skillEntries.length, 4);+ assert.equal(skillEntries[3][0], skillName);+ assert.equal(Object.values(skillEntries[3][1].effects)[0].type, effectType);+ assert.deepEqual(skillEntries[3][1].requirements, [+ { level: 1, type: "engagement_type", value: engagement }+ ]);++ const matchingSide = engagement === "rally" ? "attacker" : "defender";+ const gatedOutSide = engagement === "rally" ? "defender" : "attacker";+ const matching = resolveFighter(+ { troops: { infantry_t1: 1 }, heroes: { [heroName]: { skill_4: 5 } } },+ matchingSide,+ config,+ "rally"+ );+ const gatedOut = resolveFighter(+ { troops: { infantry_t1: 1 }, heroes: { [heroName]: { skill_4: 5 } } },+ gatedOutSide,+ config,+ "rally"+ );+ assert.deepEqual(matching.heroes[0]?.skillIds, [skillName]);+ assert.deepEqual(gatedOut.heroes[0]?.skillIds, []);+ }+});++test("Gen8 combat skill values and active effect buckets match their definitions", () => {+ const config = loadSimulatorConfig();+ const gatot = config.heroDefinitions.Gatot.skills;+ const sonya = config.heroDefinitions.Sonya.skills;+ const hendrik = config.heroDefinitions.Hendrik.skills;++ assert.deepEqual(gatot.GoldenGuard.effects["GoldenGuard/1"].value, [6, 12, 18, 24, 30]);+ assert.equal(gatot.GoldenGuard.effects["GoldenGuard/1"].type, "active.hero.defense.up");+ assert.deepEqual(gatot.KingsBestowal.effects["KingsBestowal/1"].value, [6, 12, 18, 24, 30]);+ assert.equal(gatot.KingsBestowal.effects["KingsBestowal/1"].type, "active.hero.damageTaken.down");+ assert.deepEqual(gatot.RoyalLegion.effects["RoyalLegion/1"].value, [5, 10, 15, 20, 25]);+ assert.equal(gatot.RoyalLegion.effects["RoyalLegion/1"].type, "active.hero.attack.down");++ assert.deepEqual(sonya.TreasureHunter.effects["TreasureHunter/1"].value, [4, 8, 12, 16, 20]);+ assert.equal(sonya.BountyTemptation.effects["BountyTemptation/1"].type, "active.hero.damage.up");+ assert.deepEqual(sonya.BountyTemptation.effects["BountyTemptation/1"].value, [15, 30, 45, 60, 75]);+ assert.equal(sonya.BountyTemptation.effects["BountyTemptation/2"].type, "active.hero.attack.up");+ assert.deepEqual(sonya.TorrentialImpact.effects["TorrentialImpact/1"].value, [50, 100, 150, 200, 250]);+ assert.equal(sonya.TorrentialImpact.effects["TorrentialImpact/2"].type, "no_attack");++ assert.deepEqual(hendrik.WormsRavage.effects["WormsRavage/1"].value, [5, 10, 15, 20, 25]);+ assert.equal(hendrik.WormsRavage.effects["WormsRavage/1"].type, "active.hero.defense.down");+ assert.deepEqual(hendrik.ArmorOfBarnacles.effects["ArmorOfBarnacles/1"].value, [6, 12, 18, 24, 30]);+ assert.equal(hendrik.ArmorOfBarnacles.effects["ArmorOfBarnacles/1"].type, "active.hero.defense.up");+ assert.deepEqual(hendrik.DragonsHeir.effects["DragonsHeir/1"].value, [8, 16, 24, 32, 40]);+ assert.equal(hendrik.DragonsHeir.effects["DragonsHeir/1"].type, "active.hero.damage.up");+});Index: simulator/src/index.ts===================================================================--- simulator/src/index.ts prev run+++ simulator/src/index.ts this run@@ -1,12 +1,11 @@export { buildSimulatorConfig, loadSimulatorConfig } from "./config";export type { RawSimulatorConfig } from "./config";-export { bearFighterInput, prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";+export { prepareBattle, runPrepared, simulateBattles } from "./simulator";export type { CompiledBattle } from "./simulator";export { BattleInputBuilder } from "./battleInputBuilder";export { applyHeroGenerationStats } from "./resolve";export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";export { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";export { classifyEffectForJob } from "./classifier";-export { calculateDamageJob } from "./damage";export type { CalibrationCaseComparison, CalibrationComparison, CalibrationComparisonRow } from "./calibration";export type * from "./types";Index: simulator/src/recorder.test.ts===================================================================--- simulator/src/recorder.test.ts prev run+++ simulator/src/recorder.test.ts this run@@ -0,0 +1,29 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import {+ BasicInfoRecorder,+ createRecorder,+ FullTraceRecorder,+ NULL_RECORDER,+ NullRecorder+} from "./recorder";++const unusedResolved = () => {+ throw new Error("recorder selection test does not resolve a battle");+};++test("createRecorder selects one explicit recorder implementation per simulation mode", () => {+ const fast = createRecorder("fast", [], unusedResolved);+ const standard = createRecorder("standard", [], unusedResolved);+ const trace = createRecorder("trace", [], unusedResolved);++ assert.equal(fast, NULL_RECORDER);+ assert.ok(fast instanceof NullRecorder);+ assert.ok(standard instanceof BasicInfoRecorder);+ assert.equal(standard instanceof FullTraceRecorder, false);+ assert.ok(trace instanceof FullTraceRecorder);+ assert.deepEqual(fast.skillReport, { attacker: [], defender: [] });+ assert.deepEqual(standard.skillReport, { attacker: [], defender: [] });+ assert.deepEqual(trace.skillReport, { attacker: [], defender: [] });+});Index: simulator/src/recorder.ts===================================================================--- simulator/src/recorder.ts prev run+++ simulator/src/recorder.ts this run@@ -1,126 +1,766 @@+import type { DamageResult } from "./damage";+import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, pctBucketFactor, rawBucketFactor, type AtomicBucket, type StaticDamageBucket, type StaticPlayerBucket, type StaticRawBucket } from "./damageBuckets";+import { sourceLabel } from "./effects";+import { selectPassiveContributions, unitBaseStats, unitPlayerBonuses, type PassiveContribution } from "./staticDamageProfile";+import { UNIT_TYPES } from "./types";import type {+ ActiveEffect,AppliedEffect,AttackIntent,AttackOutcome,BattleTrace,+ DamageAggregationGroupTrace,+ DamageBucketTrace,+ DamageEquationTrace,DamageJob,+ ResolvedFighter,+ ResolvedSkill,SideId,SkillReportEntry,- SimulationMode+ SimulationMode,+ UnitType} from "./types";-import type { DamageResult } from "./damage";+type RejectedEffectReason = "same_effect_max_suppressed" | "same_effect_max_superseded";+type GroupPlacement = "numerator" | "denominator";+type NumericBucketId = number;++interface DamageFactorTerm {+ id: string;+ bucket: NumericBucketId;+ bucketName: AtomicBucket;+ placement: GroupPlacement;+ appliesTo?: DamageJob["kind"];+}++export interface DamageRecordingResult {+ job: DamageJob;+ factors: Float64Array;+ armyTerm: number;+ rawDamage: number;+ kills: number;+}++/** Per-damage-job observation sink selected by the enclosing battle recorder. */+export interface DamageJobRecorder {+ recordModifier(effect: ActiveEffect, valuePct: number): void;+ recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void;+ finish(result: DamageRecordingResult): DamageResult;+}++// Recorder-side description of how the static damage factors were assembled: the same selection+// the numeric profile uses (via selectPassiveContributions) expanded into per-bucket terms with+// contributors. The battle runtime never sees these shapes; fast mode never builds them.+interface StaticProfileTerm {+ raw?: number;+ totalPct?: number;+ factor: number;+ contributors: DamageBucketTrace["contributors"];+}++interface StaticProfileEntry {+ buckets: Partial<Record<StaticDamageBucket, StaticProfileTerm>>;+}++interface StaticProfileDescription {+ dealer: Record<SideId, Record<UnitType, StaticProfileEntry>>;+ taker: Record<SideId, Record<UnitType, StaticProfileEntry>>;+}++// Per-(side, unit) applied-effect fragments prebuilt from the static profile description; the+// per-job recorders splice them into each damage event instead of re-walking the description.+interface StaticAppliedIndex {+ dealer: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;+ taker: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;+}+/**- * Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop- * does all simulation-affecting work itself (kills, counters, effect usage charging); the recorder- * only records, so swapping recorders never changes the outcome. This keeps a single, linear- * loop with no `if (mode === ...)` branches scattered through it.- *- * - fast -> NULL_RECORDER: records nothing; zero per-attack allocation.- * - standard -> RecordingRecorder: per-attack AttackOutcome[] (no equation traces).- * - trace -> RecordingRecorder + capturesTrace: outcomes with traces + per-round trace.+ * Observes the battle without deciding combat behavior. The concrete recorder is selected once,+ * before the round loop, and every simulation path emits the same observations to it.*/export interface BattleRecorder {- /** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */- readonly capturesTrace: boolean;- /** When true the loop records lightweight "effect applied" events on each AttackOutcome. */- readonly capturesAppliedEffects: boolean;- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;- recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;- recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;+ /**+ * Receives the battle's fighters and setup effects before any damage job runs. Recorders that+ * report static contributions build their description of the static profile here; the null+ * recorder does nothing, so fast mode pays nothing.+ */+ recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void;+ startDamageJob(): DamageJobRecorder;+ recordSkillTriggerAttempt(skill: ResolvedSkill): void;+ recordSkillTriggered(skill: ResolvedSkill): void;+ recordSkillEffectActivated(skill: ResolvedSkill): void;+ recordSkillDamageJob(job: DamageJob, effect: ActiveEffect): void;+ recordBattleOrder(intentId: string, effect: ActiveEffect, chosenTarget: UnitType): void;+ recordScheduledDamageJob(job: DamageJob): void;+ recordExtraAttack(normalJob: DamageJob, effect: ActiveEffect, jobs: DamageJob[], firstJobIndex: number, jobCount: number, processedJobIds: Set<string>): void;+ recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void;+ recordDamageJob(job: DamageJob, result: DamageResult): void;+ recordFinalKills(result: DamageResult): void;+ recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[]): void;readonly attacks: AttackOutcome[];+ readonly skillReport: Record<SideId, SkillReportEntry[]>;readonly trace: BattleTrace | undefined;}const NO_ATTACKS: AttackOutcome[] = [];const NO_APPLIED_EFFECTS: AppliedEffect[] = [];+const EMPTY_SKILL_REPORT: Record<SideId, SkillReportEntry[]> = { attacker: [], defender: [] };+const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();-export const NULL_RECORDER: BattleRecorder = {- capturesTrace: false,- capturesAppliedEffects: false,- recordCancelled() {},- recordDamageJob() {},- recordRound() {},- attacks: NO_ATTACKS,- trace: undefined+const NULL_DAMAGE_JOB_RECORDER: DamageJobRecorder = {+ recordModifier() {},+ recordRejected() {},+ finish({ kills }) {+ return { kills };+ }};+export class NullRecorder implements BattleRecorder {+ readonly attacks = NO_ATTACKS;+ readonly skillReport = EMPTY_SKILL_REPORT;+ readonly trace = undefined;++ startDamageJob(): DamageJobRecorder {+ return NULL_DAMAGE_JOB_RECORDER;+ }++ recordStaticProfile() {}+ recordBattleOrder() {}+ recordSkillTriggerAttempt() {}+ recordSkillTriggered() {}+ recordSkillEffectActivated() {}+ recordSkillDamageJob() {}+ recordScheduledDamageJob() {}+ recordExtraAttack() {}+ recordCancelled() {}+ recordDamageJob() {}+ recordFinalKills() {}+ recordRound() {}+}++export const NULL_RECORDER: BattleRecorder = new NullRecorder();+export function createRecorder(mode: SimulationMode,- skillReports: Record<SideId, Map<string, SkillReportEntry>>,+ fighters: ResolvedFighter[],makeResolved: () => BattleTrace["resolved"]): BattleRecorder {if (mode === "fast") return NULL_RECORDER;- return new RecordingRecorder(skillReports, mode === "trace" ? { resolved: makeResolved(), rounds: [] } : undefined);+ if (mode === "trace") return new FullTraceRecorder(fighters, makeResolved);+ return new BasicInfoRecorder(fighters);}-class RecordingRecorder implements BattleRecorder {+export class BasicInfoRecorder implements BattleRecorder {readonly attacks: AttackOutcome[] = [];- readonly trace: BattleTrace | undefined;- readonly capturesTrace: boolean;- readonly capturesAppliedEffects = true;+ protected staticApplied?: StaticAppliedIndex;+ protected readonly orderEvents = new Map<string, AppliedEffect>();+ protected readonly extraAttackEvents = new Map<string, AppliedEffect[]>();+ protected readonly skillReports: Record<SideId, Map<string, SkillReportEntry>> = {+ attacker: new Map(),+ defender: new Map()+ };+ protected readonly skillDamageKeys = new Map<string, string>();- constructor(- private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,- trace: BattleTrace | undefined- ) {- this.trace = trace;- this.capturesTrace = trace !== undefined;+ constructor(fighters: ResolvedFighter[] = []) {+ for (const fighter of fighters) {+ for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {+ this.skillReports[fighter.side].set(skillReportKey(skill), {+ sourceKind: skill.sourceKind,+ heroName: skill.heroName,+ troopType: skill.troopType,+ skillId: skill.id,+ skillName: skill.name,+ level: skill.level,+ triggersSeen: 0,+ skillActivations: 0,+ effectActivations: 0,+ skillKills: 0,+ unsupportedEffects: []+ });+ }+ }}- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {+ get trace(): BattleTrace | undefined {+ return undefined;+ }++ get skillReport(): Record<SideId, SkillReportEntry[]> {+ return {+ attacker: [...this.skillReports.attacker.values()],+ defender: [...this.skillReports.defender.values()]+ };+ }++ recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void {+ this.applyStaticDescription(buildStaticProfileDescription(fighters, setupEffects));+ }++ protected applyStaticDescription(description: StaticProfileDescription): void {+ this.staticApplied = {+ dealer: staticAppliedFragments(description.dealer),+ taker: staticAppliedFragments(description.taker)+ };+ }++ startDamageJob(): DamageJobRecorder {+ return new BasicDamageJobRecorder(this.staticApplied);+ }++ recordSkillTriggerAttempt(_skill: ResolvedSkill): void {}++ recordSkillTriggered(skill: ResolvedSkill): void {+ this.incrementSkillReport(skill, "skillActivations");+ }++ recordSkillEffectActivated(skill: ResolvedSkill): void {+ this.incrementSkillReport(skill, "effectActivations");+ }++ recordSkillDamageJob(job: DamageJob, effect: ActiveEffect): void {+ const key = skillReportKeyForEffect(effect);+ if (key) this.skillDamageKeys.set(job.id, key);+ }++ recordBattleOrder(intentId: string, effect: ActiveEffect, chosenTarget: UnitType): void {+ this.orderEvents.set(intentId, {+ kind: "battle_order",+ ...appliedEffectBase(effect),+ chosenTarget+ });+ }++ recordScheduledDamageJob(_job: DamageJob): void {}++ recordExtraAttack(+ normalJob: DamageJob,+ effect: ActiveEffect,+ jobs: DamageJob[],+ firstJobIndex: number,+ jobCount: number,+ processedJobIds: Set<string>+ ): void {+ const spawnedJobIds: string[] = [];+ const end = firstJobIndex + jobCount;+ for (let index = firstJobIndex; index < end; index += 1) {+ const jobId = jobs[index]?.id;+ if (jobId && processedJobIds.has(jobId)) spawnedJobIds.push(jobId);+ }+ if (spawnedJobIds.length === 0) return;+ const event: AppliedEffect = {+ kind: "extra_attack",+ ...appliedEffectBase(effect),+ spawnedJobIds+ };+ const events = this.extraAttackEvents.get(normalJob.id);+ if (events) events.push(event);+ else this.extraAttackEvents.set(normalJob.id, [event]);+ }++ recordCancelled(intent: AttackIntent, effect: ActiveEffect, reason: "dodge" | "no_attack"): void {+ const order = this.orderEvents.get(intent.id);+ this.orderEvents.delete(intent.id);this.attacks.push({jobId: `${intent.id}:cancelled`,round: intent.round,kind: "normal",- attackerSide: intent.attackerSide,- attackerUnit: intent.attackerUnit,- defenderSide: intent.defenderSide,- defenderUnit: intent.defenderUnit,+ dealerSide: intent.dealerSide,+ dealerUnit: intent.dealerUnit,+ takerSide: intent.takerSide,+ takerUnit: intent.takerUnit,kills: 0,- counterDeltas: [- { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },- { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }- ],- appliedEffects,- cancelledBy: effectId,+ counterDeltas: counterDeltas(intent, "normal_attack"),+ appliedEffects: order+ ? [order, { kind: "control", ...appliedEffectBase(effect), reason }]+ : [{ kind: "control", ...appliedEffectBase(effect), reason }],+ cancelledBy: effect.id,cancelReason: reason});}- recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void {- if (job.kind === "skill" && job.sourceSkillReportKey && result.kills > 0) {- const report = this.skillReports[job.attackerSide].get(job.sourceSkillReportKey);+ recordDamageJob(job: DamageJob, result: DamageResult): void {+ const sourceSkillReportKey = this.skillDamageKeys.get(job.id);+ this.skillDamageKeys.delete(job.id);+ if (job.kind === "skill" && sourceSkillReportKey && result.kills > 0) {+ const report = this.skillReports[job.dealerSide].get(sourceSkillReportKey);if (report) report.skillKills += result.kills;}const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";+ const order = job.sourceIntentId ? this.orderEvents.get(job.sourceIntentId) : undefined;+ if (job.sourceIntentId) this.orderEvents.delete(job.sourceIntentId);+ const extras = this.extraAttackEvents.get(job.id);+ this.extraAttackEvents.delete(job.id);this.attacks.push({jobId: job.id,round: job.round,kind: job.kind,sourceEffectId: job.sourceEffectId,- sourceSkillReportKey: job.sourceSkillReportKey,- attackerSide: job.attackerSide,- attackerUnit: job.attackerUnit,- defenderSide: job.defenderSide,- defenderUnit: job.defenderUnit,+ sourceSkillReportKey,+ dealerSide: job.dealerSide,+ dealerUnit: job.dealerUnit,+ takerSide: job.takerSide,+ takerUnit: job.takerUnit,kills: result.kills,- counterDeltas: [- { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },- { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }- ],- appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),+ counterDeltas: counterDeltas(job, cause),+ appliedEffects: mergeAppliedEffects(result.appliedEffects, order, extras),trace: result.trace});}- recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void {- this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });+ recordFinalKills(_result: DamageResult): void {}++ recordRound(_round: number, _roundStartTroops: DamageJob["roundStartTroops"], _intents: AttackIntent[]): void {+ this.orderEvents.clear();+ this.extraAttackEvents.clear();+ this.skillDamageKeys.clear();}++ protected incrementSkillReport(skill: ResolvedSkill, field: "triggersSeen" | "skillActivations" | "effectActivations"): void {+ const report = this.skillReports[skill.side].get(skillReportKey(skill));+ if (report) report[field] += 1;+ }}-function mergeAppliedEffects(applied: AppliedEffect[] | undefined, extra: AppliedEffect[] | undefined): AppliedEffect[] {- if (!applied?.length) return extra?.length ? extra : NO_APPLIED_EFFECTS;- if (!extra?.length) return applied;- return [...applied, ...extra];+export class FullTraceRecorder extends BasicInfoRecorder {+ private readonly rounds: BattleTrace["rounds"] = [];+ private readonly roundJobs: DamageJob[] = [];+ private staticDescription?: StaticProfileDescription;++ constructor(+ fighters: ResolvedFighter[],+ private readonly makeResolved: () => BattleTrace["resolved"]+ ) { super(fighters); }++ protected override applyStaticDescription(description: StaticProfileDescription): void {+ super.applyStaticDescription(description);+ this.staticDescription = description;+ }++ override startDamageJob(): DamageJobRecorder {+ return new FullDamageJobRecorder(this.staticApplied, this.staticDescription);+ }++ override recordSkillTriggerAttempt(skill: ResolvedSkill): void {+ this.incrementSkillReport(skill, "triggersSeen");+ }++ override recordScheduledDamageJob(job: DamageJob): void {+ const sourceSkillReportKey = this.skillDamageKeys.get(job.id);+ this.roundJobs.push(sourceSkillReportKey ? { ...job, sourceSkillReportKey } : job);+ }++ override recordFinalKills(result: DamageResult): void {+ if (result.trace) result.trace.finalKills = result.kills;+ }++ override recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[]): void {+ this.rounds.push({ round, roundStartTroops, intents, jobs: this.roundJobs.splice(0) });+ super.recordRound(round, roundStartTroops, intents);+ }++ override get trace(): BattleTrace {+ return { resolved: this.makeResolved(), rounds: this.rounds };+ }}++class BasicDamageJobRecorder implements DamageJobRecorder {+ protected readonly appliedEffects: DamageEquationTrace["appliedEffects"] = [];++ constructor(private readonly staticApplied?: StaticAppliedIndex) {}++ recordModifier(effect: ActiveEffect, valuePct: number): void {+ if (valuePct === 0) return;+ const applied = {+ kind: "modifier" as const,+ activeEffectId: effect.id,+ effectId: effect.source.effectId ?? effect.id,+ bucket: effect.intent.type,+ valuePct,+ source: sourceLabel(effect),+ sourceSide: effect.ownerSide,+ sameEffectStacking: effect.sameEffectStacking,+ ...(effect.stackingKey === undefined ? {} : { stackingKey: effect.stackingKey })+ };+ this.appliedEffects.push(applied);+ }++ recordRejected(_effect: ActiveEffect, _reason: RejectedEffectReason): void {}++ finish({ job, kills }: DamageRecordingResult): DamageResult {+ return { kills, appliedEffects: this.appliedEffectsFor(job) };+ }++ // Dynamic modifiers first, then the prebuilt static passive fragments for the job's+ // participants — the same order the per-job walk used to produce.+ protected appliedEffectsFor(job: DamageJob): DamageEquationTrace["appliedEffects"] {+ const statics = this.staticApplied;+ if (!statics) return this.appliedEffects;+ const dealer = statics.dealer[job.dealerSide][job.dealerUnit];+ const taker = statics.taker[job.takerSide][job.takerUnit];+ if (dealer.length === 0 && taker.length === 0) return this.appliedEffects;+ return [...this.appliedEffects, ...dealer, ...taker];+ }+}++class FullDamageJobRecorder extends BasicDamageJobRecorder {+ private readonly contributors: DamageBucketTrace["contributors"][] = Array.from(+ { length: ATOMIC_BUCKETS.length },+ () => []+ );+ private readonly rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];++ constructor(+ staticApplied?: StaticAppliedIndex,+ private readonly staticDescription?: StaticProfileDescription+ ) { super(staticApplied); }++ override recordModifier(effect: ActiveEffect, valuePct: number): void {+ this.contributors[effect.bucketIndex]?.push({+ effectId: effect.source.effectId ?? effect.id,+ source: sourceLabel(effect),+ sourceSide: effect.ownerSide,+ valuePct,+ bucket: effect.intent.type,+ stackingKey: effect.stackingKey,+ sameEffectStacking: effect.sameEffectStacking+ });+ super.recordModifier(effect, valuePct);+ }++ override recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void {+ this.rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason });+ }++ override finish(result: DamageRecordingResult): DamageResult {+ const basic = super.finish(result);+ const job = result.job;+ const staticEntries = this.staticDescription+ ? [this.staticDescription.dealer[job.dealerSide][job.dealerUnit], this.staticDescription.taker[job.takerSide][job.takerUnit]]+ : [];+ return {+ ...basic,+ trace: {+ roundStartTroops: {+ attacker: { ...job.roundStartTroops.attacker },+ defender: { ...job.roundStartTroops.defender }+ },+ armyTerm: result.armyTerm,+ atomicBuckets: toTraceBuckets(result.factors, this.contributors, staticEntries),+ aggregationGroups: buildAggregationGroups(job, result.factors, this.contributors, staticEntries),+ appliedEffects: basic.appliedEffects ?? [],+ rejectedEffects: this.rejectedEffects,+ rawDamage: result.rawDamage,+ finalKills: result.kills+ }+ };+ }+}++const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;+const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));+const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");+const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");++function factorTerm(bucket: AtomicBucket): DamageFactorTerm {+ const definition = BUCKET_DEFINITIONS[bucket];+ return {+ id: bucket,+ bucket: BUCKET_IDS[bucket],+ bucketName: bucket,+ placement: definition.placement,+ appliesTo: definition.appliesTo+ };+}++function buildStaticProfileDescription(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): StaticProfileDescription {+ const description: StaticProfileDescription = {+ dealer: {+ attacker: buildSideEntries(fighters.attacker, "dealer"),+ defender: buildSideEntries(fighters.defender, "dealer")+ },+ taker: {+ attacker: buildSideEntries(fighters.attacker, "taker"),+ defender: buildSideEntries(fighters.defender, "taker")+ }+ };+ for (const contribution of selectPassiveContributions(setupEffects)) {+ addPassiveTerm(description[contribution.role][contribution.side][contribution.unit], contribution);+ }+ return description;+}++function buildSideEntries(fighter: ResolvedFighter, role: "dealer" | "taker"): Record<UnitType, StaticProfileEntry> {+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildStaticEntry(fighter, unit, role)])) as Record<UnitType, StaticProfileEntry>;+}++function buildStaticEntry(fighter: ResolvedFighter, unit: UnitType, role: "dealer" | "taker"): StaticProfileEntry {+ const stats = unitBaseStats(fighter, unit);+ const bonuses = unitPlayerBonuses(fighter, unit);+ const buckets: StaticProfileEntry["buckets"] = {};+ if (role === "dealer") {+ setRawTerm(buckets, "troops.baseAttack", stats.attack);+ setRawTerm(buckets, "troops.baseLethality", stats.lethality);+ setPlayerTerm(buckets, "player.attack", bonuses.attack);+ setPlayerTerm(buckets, "player.lethality", bonuses.lethality);+ } else {+ setRawTerm(buckets, "troops.baseHealth", stats.health);+ setRawTerm(buckets, "troops.baseDefense", stats.defense);+ setPlayerTerm(buckets, "player.health", bonuses.health);+ setPlayerTerm(buckets, "player.defense", bonuses.defense);+ }+ return { buckets };+}++function setRawTerm(buckets: StaticProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {+ buckets[bucket] = { raw, factor: rawBucketFactor(raw), contributors: [] };+}++function setPlayerTerm(buckets: StaticProfileEntry["buckets"], bucket: StaticPlayerBucket, valuePct: number): void {+ buckets[bucket] = {+ totalPct: valuePct,+ factor: pctBucketFactor(valuePct),+ contributors: [{ effectId: `input:${bucket.slice("player.".length)}`, source: "input_stats", valuePct, bucket }]+ };+}++function addPassiveTerm(entry: StaticProfileEntry, contribution: PassiveContribution): void {+ const { bucket, effect, valuePct } = contribution;+ const contributor = {+ effectId: effect.source.effectId ?? effect.id,+ source: sourceLabel(effect),+ sourceSide: effect.ownerSide,+ valuePct,+ bucket,+ stackingKey: effect.stackingKey,+ sameEffectStacking: effect.sameEffectStacking+ };+ const existing = entry.buckets[bucket];+ if (existing) {+ existing.totalPct = (existing.totalPct ?? 0) + valuePct;+ existing.factor = pctBucketFactor(existing.totalPct);+ existing.contributors.push(contributor);+ } else {+ entry.buckets[bucket] = { totalPct: valuePct, factor: pctBucketFactor(valuePct), contributors: [contributor] };+ }+}++function staticAppliedFragments(+ entries: Record<SideId, Record<UnitType, StaticProfileEntry>>+): Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>> {+ return Object.fromEntries(+ (Object.entries(entries) as Array<[SideId, Record<UnitType, StaticProfileEntry>]>).map(([side, units]) => [+ side,+ Object.fromEntries(+ (Object.entries(units) as Array<[UnitType, StaticProfileEntry]>).map(([unit, entry]) => [unit, passiveAppliedEffects(entry)])+ )+ ])+ ) as Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;+}++function passiveAppliedEffects(entry: StaticProfileEntry): DamageEquationTrace["appliedEffects"] {+ const applied: DamageEquationTrace["appliedEffects"] = [];+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {+ if (!bucket.startsWith("passive.") || !term) continue;+ for (const contributor of term.contributors) {+ applied.push({+ kind: "modifier",+ activeEffectId: contributor.effectId,+ effectId: contributor.effectId,+ bucket,+ valuePct: contributor.valuePct,+ source: contributor.source,+ sourceSide: contributor.sourceSide,+ stackingKey: contributor.stackingKey,+ sameEffectStacking: contributor.sameEffectStacking+ });+ }+ }+ return applied;+}++function buildAggregationGroups(+ job: DamageJob,+ factors: Float64Array,+ contributors: DamageBucketTrace["contributors"][],+ staticEntries: StaticProfileEntry[]+): Record<string, DamageAggregationGroupTrace> {+ const groups: Record<string, DamageAggregationGroupTrace> = {};+ addStaticAggregationGroups(groups, staticEntries);+ for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {+ if (term.appliesTo && term.appliesTo !== job.kind) continue;+ const definition = BUCKET_DEFINITIONS[term.bucketName];+ const factor = factors[term.bucket];+ groups[term.id] = definition.valueType === "raw"+ ? {+ id: term.id,+ mode: "raw",+ placement: term.placement,+ inputBuckets: [term.bucketName],+ factor,+ contributors: contributors[term.bucket] ?? []+ }+ : {+ id: term.id,+ mode: "sum_pct",+ placement: term.placement,+ inputBuckets: [term.bucketName],+ totalPct: pctFromFactor(factor),+ factor,+ contributors: contributors[term.bucket] ?? []+ };+ }+ return groups;+}++function addStaticAggregationGroups(+ groups: Record<string, DamageAggregationGroupTrace>,+ staticEntries: StaticProfileEntry[]+): void {+ const dealer = staticEntries[0];+ const taker = staticEntries[1];+ if (!dealer || !taker) return;+ addStaticRawGroup(groups, "troops.baseAttack", "numerator", dealer, "troops.baseAttack");+ addStaticRawGroup(groups, "troops.baseLethality", "numerator", dealer, "troops.baseLethality");+ addStaticPctGroup(groups, "player.dealer.attack", "numerator", dealer, "player.attack");+ addStaticPctGroup(groups, "player.dealer.lethality", "numerator", dealer, "player.lethality");+ addStaticPctGroup(groups, "passive.dealer.attack.up", "numerator", dealer, "passive.attack.up");+ addStaticPctGroup(groups, "passive.dealer.lethality.up", "numerator", dealer, "passive.lethality.up");+ addStaticPctGroup(groups, "passive.taker.health.down", "numerator", taker, "passive.health.down");+ addStaticPctGroup(groups, "passive.taker.defense.down", "numerator", taker, "passive.defense.down");+ addStaticRawGroup(groups, "troops.baseHealth", "denominator", taker, "troops.baseHealth");+ addStaticRawGroup(groups, "troops.baseDefense", "denominator", taker, "troops.baseDefense");+ addStaticPctGroup(groups, "player.taker.health", "denominator", taker, "player.health");+ addStaticPctGroup(groups, "player.taker.defense", "denominator", taker, "player.defense");+ addStaticPctGroup(groups, "passive.dealer.attack.down", "denominator", dealer, "passive.attack.down");+ addStaticPctGroup(groups, "passive.dealer.lethality.down", "denominator", dealer, "passive.lethality.down");+ addStaticPctGroup(groups, "passive.taker.health.up", "denominator", taker, "passive.health.up");+ addStaticPctGroup(groups, "passive.taker.defense.up", "denominator", taker, "passive.defense.up");+}++function addStaticRawGroup(+ groups: Record<string, DamageAggregationGroupTrace>,+ id: string,+ placement: GroupPlacement,+ entry: StaticProfileEntry,+ bucket: StaticDamageBucket+): void {+ const term = entry.buckets[bucket];+ groups[id] = {+ id,+ mode: "raw",+ placement,+ inputBuckets: [bucket],+ factor: term?.factor ?? 0,+ contributors: term?.contributors ?? []+ };+}++function addStaticPctGroup(+ groups: Record<string, DamageAggregationGroupTrace>,+ id: string,+ placement: GroupPlacement,+ entry: StaticProfileEntry,+ bucket: StaticDamageBucket+): void {+ const term = entry.buckets[bucket];+ groups[id] = {+ id,+ mode: "sum_pct",+ placement,+ inputBuckets: [bucket],+ totalPct: term?.totalPct ?? 0,+ factor: term?.factor ?? 1,+ contributors: term?.contributors ?? []+ };+}++function toTraceBuckets(+ factors: Float64Array,+ contributors: DamageBucketTrace["contributors"][],+ staticEntries: StaticProfileEntry[]+): Record<string, DamageBucketTrace> {+ const traced = Object.fromEntries(+ ATOMIC_BUCKETS.map((bucket, index) => {+ const definition = BUCKET_DEFINITIONS[bucket];+ const factor = factors[index];+ const bucketContributors = contributors[index] ?? [];+ return definition.valueType === "raw"+ ? [bucket, { raw: factor, factor, contributors: [...bucketContributors] }]+ : [bucket, { totalPct: pctFromFactor(factor), factor, contributors: [...bucketContributors] }];+ })+ ) as Record<string, DamageBucketTrace>;+ for (const entry of staticEntries) {+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {+ if (!term) continue;+ traced[bucket] = term.raw !== undefined+ ? { raw: term.raw, factor: term.factor, contributors: [...term.contributors] }+ : { totalPct: term.totalPct ?? 0, factor: term.factor, contributors: [...term.contributors] };+ }+ }+ return traced;+}++function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {+ return {+ activeEffectId: effect.id,+ effectId: effect.source.effectId ?? effect.id,+ source: sourceLabel(effect),+ sourceSide: effect.ownerSide+ };+}++function counterDeltas(+ attack: Pick<AttackIntent | DamageJob, "dealerSide" | "dealerUnit" | "takerSide" | "takerUnit">,+ cause: "normal_attack" | "extra_skill_attack"+): AttackOutcome["counterDeltas"] {+ return [+ { side: attack.dealerSide, unit: attack.dealerUnit, counter: "attacks", by: 1, cause },+ { side: attack.takerSide, unit: attack.takerUnit, counter: "received_attacks", by: 1, cause }+ ];+}++function skillReportKey(skill: ResolvedSkill): string {+ const cached = REPORT_KEY_CACHE.get(skill);+ if (cached) return cached;+ const key = skillReportKeyFromParts(+ skill.sourceKind,+ skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "",+ skill.id+ );+ REPORT_KEY_CACHE.set(skill, key);+ return key;+}++function skillReportKeyForEffect(effect: ActiveEffect): string | undefined {+ const source = effect.source;+ if ((source.kind !== "hero_skill" && source.kind !== "troop_skill") || !source.skillId) return undefined;+ return skillReportKeyFromParts(+ source.kind,+ source.heroInstanceId ?? source.heroName ?? source.troopType ?? "",+ source.skillId+ );+}++function skillReportKeyFromParts(+ sourceKind: SkillReportEntry["sourceKind"],+ sourceKey: string,+ skillId: string+): string {+ return `${sourceKind}:${sourceKey}:${skillId}`;+}++function mergeAppliedEffects(+ applied: AppliedEffect[] | undefined,+ order: AppliedEffect | undefined,+ extras: AppliedEffect[] | undefined+): AppliedEffect[] {+ if (!applied?.length && !order && !extras?.length) return NO_APPLIED_EFFECTS;+ if (!order && !extras?.length) return applied ?? NO_APPLIED_EFFECTS;+ if (!applied?.length && !extras?.length) return order ? [order] : NO_APPLIED_EFFECTS;+ if (!applied?.length && !order) return extras ?? NO_APPLIED_EFFECTS;+ return [...(applied ?? []), ...(order ? [order] : []), ...(extras ?? [])];+}++function pctFromFactor(factor: number): number {+ return Number(((factor - 1) * 100).toFixed(12));+}Index: simulator/src/resolve.ts===================================================================--- simulator/src/resolve.ts prev run+++ simulator/src/resolve.ts this run@@ -3,8 +3,9 @@FighterInput,HeroInputCollection,ResolvedFighter,ResolvedHero,+ ResolvedEffectIntentDefinition,ResolvedSkill,ResolvedTroopLine,SideId,SimulatorConfig,@@ -297,14 +298,15 @@troopType?: UnitType,heroInstanceId?: string,heroRole?: "main" | "joiner"): ResolvedSkill {- const effects: EffectIntentDefinition[] = [];+ const effects: ResolvedEffectIntentDefinition[] = [];for (const [effectId, effect] of Object.entries(rawSkill.effects ?? {})) {effects.push({id: effectId,...effect,- value: levelSelectPreservingOrder(effect.value, level)+ value: levelSelectPreservingOrder(effect.value, level),+ sourceDefinition: effect});}return {id: skillId,Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -5,11 +5,15 @@import { loadSimulatorConfig } from "./config";import { createSeededRng, chancePasses } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./resolve";-import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";+import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";+function runOnce(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}) {+ return runPrepared(prepareBattle(input, config), undefined, options);+}+test("runPrepared reuses the compiled input seed when no override is supplied", () => {const config = loadSimulatorConfig();const stats = {inf: { attack: 200, defense: 200, lethality: 150, health: 150 },@@ -20,22 +24,40 @@seed: "runprepared-seed-regression",attacker: { troops: { infantry_t6: 5000, lancer_t6: 3000, marksman_t6: 4000 }, stats, heroes: { Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } } },defender: { troops: { infantry_t6: 5000, lancer_t6: 3000, marksman_t6: 4000 }, stats, heroes: { Greg: { skill_1: 5, skill_2: 5 } } }};- const direct = simulateBattle(input, config);+ const compiled = prepareBattle(input, config);+ const direct = runPrepared(compiled, input.seed);// Only meaningful if chance triggers make the round loop seed-sensitive.assert.equal(direct.randomness.deterministic, false, "expected a stochastic battle");- // runPrepared with no seed override must reproduce simulateBattle using the compiled input seed,+ // runPrepared with no seed override must reproduce an explicit compiled-input seed,// not silently fall back to the default seed.- const prepared = runPrepared(prepareBattle(input, config));+ const prepared = runPrepared(compiled);assert.equal(prepared.winner, direct.winner);assert.equal(prepared.rounds, direct.rounds);assert.deepEqual(prepared.remaining, direct.remaining);});-test("simulateBattle returns structured result for a no-hero battle", () => {+test("simulateBattles returns the requested replicates and rejects invalid counts", () => {const config = loadSimulatorConfig();- const result = simulateBattle(+ const input: BattleInput = {+ seed: "replicates",+ maxRounds: 1,+ attacker: { troops: { infantry_t1: 1000 }, stats: {}, heroes: {} },+ defender: { troops: { infantry_t1: 1000 }, stats: {}, heroes: {} }+ };++ const results = simulateBattles(input, config, { count: 3, mode: "fast" });++ assert.equal(results.length, 3);+ assert.ok(results.every((result) => result.attacks.length === 0));+ assert.throws(() => simulateBattles(input, config, { count: 0 }), /positive integer/i);+ assert.throws(() => simulateBattles(input, config, { count: 1.5 }), /positive integer/i);+});++test("runPrepared returns structured result for a no-hero battle", () => {+ const config = loadSimulatorConfig();+ const result = runOnce({maxRounds: 3,attacker: {name: "A",@@ -62,9 +84,9 @@assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));});test("standard battle outcomes include round and applied effects without full traces", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 1000 },@@ -94,14 +116,77 @@}}));- const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");assert.equal(attack?.round, 1);assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);assert.equal(attack?.trace, undefined);});+test("fast, standard, and trace recorders expose their complete output contracts", () => {+ const input: BattleInput = {+ maxRounds: 1,+ seed: "recorder-contract",+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: { RecorderHero: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ }+ };+ const config = minimalConfig({+ RecorderHero: {+ name: "RecorderHero",+ troop_type: "infantry",+ skills: {+ RecordedBoost: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.attack.up",+ value: 25,+ units: { applies_to: "self.infantry", applies_vs: "any" }+ }+ }+ }+ }+ }+ });++ const fast = runOnce(input, config, { mode: "fast" });+ const standard = runOnce(input, config, { mode: "standard" });+ const trace = runOnce(input, config, { mode: "trace" });++ assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(standard));+ assert.deepEqual(semanticBattleSummary(trace), semanticBattleSummary(standard));+ assert.deepEqual(fast.attacks, []);+ assert.deepEqual(fast.skillReport, { attacker: [], defender: [] });+ assert.equal(fast.trace, undefined);++ assert.ok(standard.attacks.length > 0);+ assert.equal(standard.attacks.every((attack) => attack.trace === undefined), true);+ assert.equal(standard.attacks.some((attack) => attack.appliedEffects.some((effect) => effect.effectId === "boost")), true);+ assert.equal(standard.trace, undefined);++ const traceAttacksWithoutEquations = trace.attacks.map(({ trace: _equation, ...attack }) => attack);+ const standardAttacksWithoutEquations = standard.attacks.map(({ trace: _equation, ...attack }) => attack);+ assert.deepEqual(traceAttacksWithoutEquations, standardAttacksWithoutEquations);+ const standardSkill = standard.skillReport.attacker.find((entry) => entry.skillId === "RecordedBoost");+ const traceSkill = trace.skillReport.attacker.find((entry) => entry.skillId === "RecordedBoost");+ assert.deepEqual(traceSkill && { ...traceSkill, triggersSeen: 0 }, standardSkill);+ assert.equal(standardSkill?.triggersSeen, 0);+ assert.equal(traceSkill?.triggersSeen, 1);+ assert.ok(trace.trace);+ assert.deepEqual(trace.trace.resolved, trace.resolved);+ assert.equal(trace.trace.rounds.length, trace.rounds);+ assert.equal(trace.trace.rounds[0]?.jobs.length, trace.attacks.length);+ assert.equal(trace.attacks.every((attack) => attack.trace?.atomicBuckets !== undefined), true);+ assert.equal(trace.attacks.every((attack) => attack.trace?.aggregationGroups !== undefined), true);+});+test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {const player: FighterInput = {name: "Player",troops: { infantry_t1: 100 },@@ -209,11 +294,11 @@assert.ok(defensive.attacks.some((attack) => attack.kills > 5000));assert.ok(damage.score > defensive.score);});-test("simulateBattle calculates all same-round damage from the round-start troop snapshot", () => {+test("runPrepared calculates all same-round damage from the round-start troop snapshot", () => {const config = loadSimulatorConfig();- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {name: "A",@@ -244,10 +329,10 @@assert.equal(result.trace?.rounds[0]?.roundStartTroops.defender.infantry, 1);assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);});-test("simulateBattle skips later attacks against same-round exhausted targets only", () => {- const result = simulateBattle(+test("runPrepared skips later attacks against same-round exhausted targets only", () => {+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100, lancer_t1: 100 },@@ -280,9 +365,9 @@assert.equal(result.remaining.defender.infantry, 0);});test("extra skill attacks against same-round exhausted targets are skipped entirely", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -315,33 +400,33 @@}),{ mode: "trace" });- const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");+ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.dealerSide === "attacker");assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);assert.deepEqual(result.extraSkillAttackJobsByEffect, {});assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);assert.equal(result.remaining.defender.lancer, 0);});-test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {+test("runPrepared carries fractional casualties between rounds and ceils final survivors", () => {const config = loadSimulatorConfig();const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;const input = testcases.find((testcase) => testcase.test_id === "simple_001");assert.notEqual(input, undefined);- const result = simulateBattle(input!, config, { mode: "trace" });+ const result = runOnce(input!, config, { mode: "trace" });assert.equal(totalRemaining(result.remaining.attacker) - totalRemaining(result.remaining.defender), -186);assert.equal(result.remaining.defender.lancer, 186);assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.003308");});-test("simulateBattle defaults to a 1500 round cap when no explicit maxRounds is provided", () => {- const result = simulateBattle(+test("runPrepared defaults to a 1500 round cap when no explicit maxRounds is provided", () => {+ const result = runOnce({attacker: { troops: {}, heroes: {} },defender: { troops: {}, heroes: {} }},@@ -352,9 +437,9 @@assert.equal(result.rounds, 1500);});test("round-start trigger source self.any rolls once then creates one target-locked effect per live unit type", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100, lancer_t1: 100 },@@ -381,19 +466,61 @@}}}}- })+ }),+ { mode: "trace" });const report = result.skillReport.attacker.find((entry) => entry.skillId === "RoundFanout");assert.equal(report?.triggersSeen, 1);assert.equal(report?.skillActivations, 1);assert.equal(report?.effectActivations, 2);});+test("failed probability gates are recorded as trigger attempts only in trace mode", () => {+ const input: BattleInput = {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100 },+ heroes: { NeverProc: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ };+ const config = minimalConfig({+ NeverProc: {+ name: "NeverProc",+ skills: {+ FailedBattleStart: {+ trigger: { type: "battle_start", probability: 0 },+ effects: {+ buff: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" }+ }+ }+ }+ }+ }+ });++ const standard = runOnce(input, config);+ const trace = runOnce(input, config, { mode: "trace" });+ const standardReport = standard.skillReport.attacker.find((entry) => entry.skillId === "FailedBattleStart");+ const traceReport = trace.skillReport.attacker.find((entry) => entry.skillId === "FailedBattleStart");++ assert.equal(standardReport?.triggersSeen, 0);+ assert.equal(standardReport?.skillActivations, 0);+ assert.equal(traceReport?.triggersSeen, 1);+ assert.equal(traceReport?.skillActivations, 0);+});+test("attack trigger source and target selectors match relative to the skill owner", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100, lancer_t1: 100 },@@ -420,9 +547,10 @@}}}}- })+ }),+ { mode: "trace" });const report = result.skillReport.defender.find((entry) => entry.skillId === "EnemyHitsInfantry");assert.equal(report?.triggersSeen, 2);@@ -430,9 +558,9 @@assert.equal(report?.effectActivations, 2);});test("attack-duration effects with a round cap expire at the end of their active turn even when unused", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 4,attacker: {troops: { lancer_t1: 1000000 },@@ -475,18 +603,18 @@}),{ mode: "trace" });- const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");+ const lancerAttacks = result.attacks.filter((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "lancer");+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.takerUnit, "marksman");+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.takerUnit, "infantry");const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));- assert.equal(round4LancerAttack?.defenderUnit, "marksman");+ assert.equal(round4LancerAttack?.takerUnit, "marksman");assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);});test("attack-duration effects with a round cap expire after one applicable attack", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { infantry_t1: 1000000 },@@ -525,9 +653,9 @@assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), false);});test("attack-duration effects with a round delay are not charged before the delay is over", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { infantry_t1: 1000000 },@@ -566,9 +694,9 @@assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { infantry_t1: 1000000 },@@ -617,9 +745,9 @@assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});test("attack delay skips eligible attacks before the effect can apply", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { marksman_t1: 1000000 },@@ -651,15 +779,16 @@}}));- const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");+ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.dealerSide === "attacker");assert.equal(skillAttacks.length, 1);assert.ok(skillAttacks[0].jobId.startsWith("r2:"));});+test("attack delay skips eligible damage jobs before a modifier can apply", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { infantry_t1: 1000000 },@@ -692,17 +821,18 @@{ mode: "trace" });const normalAttacks = result.attacks.filter(- (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"+ (attack) => attack.kind === "normal" && attack.dealerSide === "attacker");assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});-test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {++test("runPrepared reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {name: "A",@@ -776,9 +906,9 @@assert.deepEqual(bakedFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });});test("array joiner heroes preserve duplicate skill instances", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100 },@@ -860,9 +990,9 @@assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });});test("cancelled attacks report the winning control as an applied effect", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 10 },@@ -914,9 +1044,9 @@]);});test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 1000, lancer_t1: 1000 },@@ -950,15 +1080,15 @@result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),["r1:attacker:infantry:0:cancelled"]);assert.equal(- result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.takerSide === "attacker" && attack.takerUnit === "infantry" && attack.cancelReason === "no_attack"),false);});test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 1000 },@@ -992,15 +1122,15 @@result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),["r1:attacker:infantry:0:cancelled"]);assert.equal(- result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "infantry" && attack.cancelReason === "dodge"),false);});test("attack-declared controls from later intents affect earlier same-round attacks", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 1000 },@@ -1077,9 +1207,9 @@}});const roundTwoKills = (options: SimulationOptions): number => {- const result = simulateBattle(input, config, options);+ const result = runOnce(input, config, options);const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));assert.notEqual(attack, undefined);return attack!.kills;};@@ -1089,9 +1219,9 @@assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);});test("cancelled normal attacks advance attack counters for later frequency checks", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 4,attacker: {troops: { infantry_t1: 100 },@@ -1127,9 +1257,9 @@);});test("attack frequency triggers can start at a different first threshold", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 14,attacker: {troops: { infantry_t1: 10000 },@@ -1165,10 +1295,10 @@);});test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {- const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });- const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });+ const maxResult = runOnce(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });+ const addResult = runOnce(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });const maxRoundTwo = maxResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");const addRoundTwo = addResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");@@ -1178,9 +1308,9 @@assert.equal(addRoundTwo?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 2);});test("same_effect_stacking max caps duplicate hero instances of the same skill effect", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100 },@@ -1202,10 +1332,10 @@assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);});test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {- const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"), { mode: "trace" });- const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"), { mode: "trace" });+ const maxResult = runOnce(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"), { mode: "trace" });+ const addResult = runOnce(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"), { mode: "trace" });const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];@@ -1215,9 +1345,9 @@assert.deepEqual(addResult.extraSkillAttackJobsByEffect, { stacking: 3 });});test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { infantry_t1: 10000 },@@ -1262,9 +1392,9 @@assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);});test("requires_effect is ignored by native simulator effect activation", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 0,attacker: {troops: { infantry_t1: 10 },@@ -1302,9 +1432,9 @@assert.equal(report?.effectActivations, 2);});test("fighter passive effects are added to the static profile after battle_start effects", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100 },@@ -1338,9 +1468,9 @@}),{ mode: "trace" });- const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);assert.equal(attack?.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);assert.deepEqual(attack?.trace?.atomicBuckets["passive.attack.up"].contributors.map((contributor) => contributor.effectId).sort(),@@ -1348,9 +1478,9 @@);});test("extra skill attacks with array trigger damage targets hit those defender unit types", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1391,18 +1521,18 @@);const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),+ skillJobs.map((job) => [job.sourceEffectId, job.takerUnit]),[["hitLancer", "lancer"],["hitMarksman", "marksman"]]);});test('extra skill attacks with applies_vs "any" keep current-target compatibility', () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1436,15 +1566,15 @@);const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),+ skillJobs.map((job) => [job.sourceEffectId, job.takerUnit]),[["hitAny", "lancer"]]);});test("skill report attributes kills only to the skill damage source", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1494,15 +1624,19 @@);const powerShot = result.skillReport.attacker.find((entry) => entry.skillId === "PowerShot");const crystalShield = result.skillReport.defender.find((entry) => entry.skillId === "CrystalShield");+ const skillOutcome = result.attacks.find((entry) => entry.kind === "skill");+ const skillJob = result.trace?.rounds.flatMap((round) => round.jobs).find((entry) => entry.kind === "skill");assert.ok((powerShot?.skillKills ?? 0) > 0);assert.equal(crystalShield?.skillKills, 0);+ assert.match(skillOutcome?.sourceSkillReportKey ?? "", /PowerShot/);+ assert.equal(skillJob?.sourceSkillReportKey, skillOutcome?.sourceSkillReportKey);});test("same-round outcomes are capped to available target troops before tracing skill kills", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 1000 },@@ -1542,9 +1676,9 @@{ mode: "trace" });const defenderLosses = result.attacks- .filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")+ .filter((attack) => attack.takerSide === "defender" && attack.takerUnit === "infantry").reduce((sum, attack) => sum + attack.kills, 0);const doubleBlast = result.skillReport.attacker.find((entry) => entry.skillId === "DoubleBlast");assert.equal(Number(defenderLosses.toFixed(6)), 10);@@ -1558,17 +1692,17 @@const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;const input = testcases.find((testcase) => testcase.test_id === "sergey_solo");if (!input) throw new Error("missing sergey_solo fixture");- const result = simulateBattle(input, config);+ const result = runOnce(input, config);assert.equal(result.winner, "attacker");assert.equal(result.remaining.attacker.marksman, 1349);assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });});test("committed losses clamp exhausted floating point residue before next-round target selection", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { infantry_t1: 10, lancer_t1: 100 },@@ -1587,14 +1721,14 @@minimalConfig(),{ mode: "trace" });const roundOneInfantryLosses = result.attacks- .filter((attack) => attack.jobId.startsWith("r1:") && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry")+ .filter((attack) => attack.jobId.startsWith("r1:") && attack.takerSide === "attacker" && attack.takerUnit === "infantry").map((attack) => attack.kills);const roundTwo = result.trace?.rounds.find((round) => round.round === 2);const defenderTargets = roundTwo?.intents- .filter((intent) => intent.attackerSide === "defender")- .map((intent) => intent.defenderUnit);+ .filter((intent) => intent.dealerSide === "defender")+ .map((intent) => intent.takerUnit);assert.ok(roundOneInfantryLosses.every((kills) => !Number.isInteger(kills)));assert.equal(roundOneInfantryLosses.reduce((sum, kills) => sum + kills, 0), 10);assert.equal(roundTwo?.roundStartTroops.attacker.infantry, 0);@@ -1603,9 +1737,9 @@test("extra skill trigger damage jobs reject missing runtime selectors", () => {assert.throws(() =>- simulateBattle(+ runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1643,9 +1777,9 @@test("extra skill attacks reject missing trigger damage jobs at activation", () => {assert.throws(() =>- simulateBattle(+ runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1679,9 +1813,9 @@);});test("attack-triggered extra skill attacks activate then resolve trigger damage jobs on normal attack use", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1719,16 +1853,16 @@const skillJobs = jobs.filter((job) => job.kind === "skill");assert.equal(normalJobs.length, 2);assert.equal(skillJobs.length, 1);- assert.equal(skillJobs[0]?.attackerSide, normalJobs[0]?.attackerSide);- assert.equal(skillJobs[0]?.attackerUnit, normalJobs[0]?.attackerUnit);- assert.equal(skillJobs[0]?.defenderSide, normalJobs[0]?.defenderSide);- assert.equal(skillJobs[0]?.defenderUnit, normalJobs[0]?.defenderUnit);+ assert.equal(skillJobs[0]?.dealerSide, normalJobs[0]?.dealerSide);+ assert.equal(skillJobs[0]?.dealerUnit, normalJobs[0]?.dealerUnit);+ assert.equal(skillJobs[0]?.takerSide, normalJobs[0]?.takerSide);+ assert.equal(skillJobs[0]?.takerUnit, normalJobs[0]?.takerUnit);});test("cancelled normal attacks do not consume extra skill attack uses", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { marksman_t1: 100 },@@ -1786,9 +1920,9 @@assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });});test("extra skill attack effects cannot be used by later enemy normal attacks", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1822,16 +1956,16 @@);const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => `${job.attackerSide}.${job.attackerUnit}->${job.defenderSide}.${job.defenderUnit}`),+ skillJobs.map((job) => `${job.dealerSide}.${job.dealerUnit}->${job.takerSide}.${job.takerUnit}`),["attacker.marksman->defender.infantry"]);assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });});test("extra skill trigger damage jobs can resolve to multiple living enemy targets without recursive attack triggers", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1877,17 +2011,17 @@);const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => job.defenderUnit).sort(),+ skillJobs.map((job) => job.takerUnit).sort(),["infantry", "infantry", "lancer", "marksman"]);assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "MultiTargetShot")?.triggersSeen, 1);assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "RecursiveGuard")?.triggersSeen, 1);});test("extra skill attack consumes one use regardless of multiple generated target jobs", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { marksman_t1: 100 },@@ -1927,9 +2061,9 @@test('direct simulator configs reject applies_vs "all" usage gates', () => {assert.throws(() =>- simulateBattle(+ runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -1963,9 +2097,9 @@);});test("extra skill attack consumes one use when multiple same-round normal attacks match the effect", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100, marksman_t1: 100 },@@ -2003,9 +2137,9 @@assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });});test("extra skill attack applies_vs must match the current normal attack target", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { marksman_t1: 100 },@@ -2039,16 +2173,16 @@);const jobs = result.trace?.rounds[0]?.jobs ?? [];assert.deepEqual(- jobs.map((job) => `${job.kind}:${job.attackerUnit}->${job.defenderUnit}`),+ jobs.map((job) => `${job.kind}:${job.dealerUnit}->${job.takerUnit}`),["normal:marksman->infantry", "normal:infantry->marksman", "normal:lancer->marksman"]);assert.deepEqual(result.extraSkillAttackJobsByEffect, {});});test("extra skill de-dupe does not suppress unrelated attack-duration effect consumption", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 2,attacker: {troops: { marksman_t1: 100 },@@ -2101,9 +2235,9 @@assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });});test("attack-triggered source and target selectors resolve to concrete active scopes", () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100 },@@ -2134,19 +2268,19 @@}),{ mode: "trace" });- const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");- const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");- const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");+ const attackerAttack = result.attacks.find((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");+ const defenderLancerAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "lancer");+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "marksman");assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);});test('attack-triggered target selector with applies_vs "any" gates later opposing attacks', () => {- const result = simulateBattle(+ const result = runOnce({maxRounds: 1,attacker: {troops: { infantry_t1: 100 },@@ -2177,11 +2311,11 @@}),{ mode: "trace" });- const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");- const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");- const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");+ const attackerAttack = result.attacks.find((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");+ const defenderLancerAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "lancer");+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "marksman");assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);assert.equal(defenderMarksmanAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);@@ -2216,11 +2350,11 @@heroes: {}}};- const defaultResult = simulateBattle(input, config);- const rallyResult = simulateBattle({ ...input, engagement_type: "rally" }, config);- const garrisonResult = simulateBattle({ ...input, engagement_type: "garrison" }, config);+ const defaultResult = runOnce(input, config);+ const rallyResult = runOnce({ ...input, engagement_type: "rally" }, config);+ const garrisonResult = runOnce({ ...input, engagement_type: "garrison" }, config);assert.equal(skillActivations(defaultResult, "RallyOnly"), 0);assert.equal(skillActivations(defaultResult, "GarrisonOnly"), 0);assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);@@ -2263,9 +2397,9 @@heroes: { Gated: { skill_1: 1, skill_2: 1 } }}};- const result = simulateBattle(input, config);+ const result = runOnce(input, config);const attackerRally = result.skillReport.attacker.find((entry) => entry.skillId === "RallyOnly");const defenderGarrison = result.skillReport.defender.find((entry) => entry.skillId === "GarrisonOnly");@@ -2276,9 +2410,9 @@assert.equal(result.skillReport.defender.some((entry) => entry.skillId === "RallyOnly"), false);assert.equal(Boolean(defenderGarrison), true);});-test("simulateBattle identifies stochastic battles from resolved chance triggers", () => {+test("runPrepared identifies stochastic battles from resolved chance triggers", () => {const config = minimalConfig({Chance: {name: "Chance",skills: {@@ -2306,10 +2440,10 @@heroes: {}}};- const chanceResult = simulateBattle(input, config);- const gatedOnlyResult = simulateBattle(+ const chanceResult = runOnce(input, config);+ const gatedOnlyResult = runOnce({...input,attacker: { ...input.attacker, heroes: { Chance: { skill_2: 1 } } }},@@ -2429,10 +2563,10 @@}];for (const { name, input, config } of cases) {- const full = simulateBattle(input, config);- const fast = simulateBattle(input, config, { mode: "fast" });+ const full = runOnce(input, config);+ const fast = runOnce(input, config, { mode: "fast" });assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(full), name);assert.deepEqual(fast.attacks, [], name);assert.equal(fast.trace, undefined, name);@@ -2456,9 +2590,9 @@joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]}};- const result = simulateBattle(input, config, { mode: "fast" });+ const result = runOnce(input, config, { mode: "fast" });const score = signedRemainingScore(result);const expected =result.winner === "attacker"@@ -2498,18 +2632,18 @@assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [0.5] } }, () => 0.005), false);assert.equal(chancePasses({ ...skill, trigger: { type: "battle_start", probability: [100] } }, createSeededRng("x")), true);});-function skillActivations(result: ReturnType<typeof simulateBattle>, skillId: string): number {+function skillActivations(result: ReturnType<typeof runOnce>, skillId: string): number {return result.skillReport.attacker.find((entry) => entry.skillId === skillId)?.skillActivations ?? 0;}function totalRemaining(troops: Record<UnitType, number>): number {return Object.values(troops).reduce((sum, count) => sum + count, 0);}-function semanticBattleSummary(result: ReturnType<typeof simulateBattle>): Pick<- ReturnType<typeof simulateBattle>,+function semanticBattleSummary(result: ReturnType<typeof runOnce>): Pick<+ ReturnType<typeof runOnce>,"winner" | "rounds" | "remaining" | "effectActivationCounts" | "extraSkillAttackJobsByEffect" | "attackControlCounts"> {return {winner: result.winner,Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -1,32 +1,27 @@import type {ActiveEffect,- AppliedControlEffect,- AppliedEffect,- AppliedExtraAttackEffect,- AppliedOrderEffect,AttackIntent,AttackOutcome,BearBattleResult,BattleInput,BattleRandomness,BattleResult,BattleTrace,DamageJob,- EffectSource,FighterInput,+ ResolvedEffectIntentDefinition,ResolvedFighter,ResolvedSkill,SideId,SimulationOptions,SimulatorConfig,- SkillReportEntry,TriggerDamageJobSelector,UnitType} from "./types";import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";-import { calculateDamageJob, createFastDamageScratch, type DamageResult, type DamageScratch } from "./damage";-import { createRecorder } from "./recorder";+import { calculateDamageJob, createDamageScratch, type DamageResult, type DamageScratch } from "./damage";+import { createRecorder, NULL_RECORDER, type BattleRecorder } from "./recorder";import {activateEffect,advanceEffectAttackDelay,chancePasses,@@ -37,33 +32,23 @@effectRoundWindow,hasAttackDurationConstraint,isEffectAttackReady,oppositeSide,+ resolvedEffectScopeKey,skillMatchesTrigger,- sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import {- cloneEffectIndex,- createEffectIndex,- damageEffectsForJob,- damageJobShapeSlot,- DAMAGE_JOB_SHAPE_SLOTS,- expireEffectIndex,- indexEffect,- isRuntimeIndexableEffect,- type EffectIndex-} from "./effectIndex";+import { cloneEffectIndex, createEffectIndex, damageBucketIndex, damageJobShapeSlot, damageJobSlot, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";+import { bucketDefinition } from "./damageBuckets";const DEFAULT_MAX_ROUNDS = 1500;const BEAR_ROUNDS = 10;const BEAR_DEFENSE = 36;const BEAR_TROOP_ID = "bear_infantry";-const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();interface Runtime {effectIndex: EffectIndex;preparedEffects: ActiveEffect[];@@ -72,12 +57,14 @@// Per-job scratch: effects that affected the job being calculated; drained by// chargeUsedEffects (uses += 1 each) after every job in every mode.usedEffects: ActiveEffect[];staticDamageProfile?: StaticDamageProfile;+ // Materialized battle_start + input passive effects; recorders derive their own static profile+ // description from these, so the runtime carries no trace-shaped data.+ setupEffects: ActiveEffect[];damageScratch: DamageScratch;rng: Rng;skills: RuntimeSkills;- skillReports: Record<SideId, Map<string, SkillReportEntry>>;effectActivationCounts: Record<SideId, number>;extraSkillAttackJobsByEffect: Record<string, number>;attackControlCounts: { dodge: number; no_attack: number };counters: {@@ -93,8 +80,10 @@roundStartGlobal: ResolvedSkill[];roundStartPerUnit: ResolvedSkill[];attackDeclared: ResolvedSkill[];attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;+ damageGroups: NonNullable<ActiveEffect["damageIndexGroup"]>[];+ damageGroupsByJobShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][];}interface ExtraAttackEffectGroup {selected: ActiveEffect;@@ -103,14 +92,16 @@interface ExtraSkillJobsResult {jobs: DamageJob[];usedEffectGroups: ExtraSkillUsedEffectGroup[];- appliedEffects?: AppliedExtraAttackEffect[];}interface ExtraSkillUsedEffectGroup {sourceEffectId: string;+ selected: ActiveEffect;effects: ActiveEffect[];+ firstJobIndex: number;+ jobCount: number;}interface BattleRun {fighters: Record<SideId, ResolvedFighter>;@@ -118,25 +109,22 @@winner: SideId | "draw";rounds: number;attacks: AttackOutcome[];trace?: BattleTrace;+ skillReport: BattleResult["skillReport"];score?: number;}interface RunLoopOptions {capRoundKills: boolean;capJobKills: boolean;commitLosses: boolean;scoreSide?: {- attackerSide: SideId;- defenderSide: SideId;+ dealerSide: SideId;+ takerSide: SideId;};}-export function simulateBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}): BattleResult {- return buildBattleResult(runBattle(input, config, options));-}-export function bearFighterInput(): FighterInput {return {name: "Bear",troops: { [BEAR_TROOP_ID]: 5000 },@@ -164,9 +152,9 @@const run = runBattle(input, configWithBearTroop(config), options, undefined, {capRoundKills: false,capJobKills: false,commitLosses: false,- scoreSide: { attackerSide: "attacker", defenderSide: "defender" }+ scoreSide: { dealerSide: "attacker", takerSide: "defender" }});return {...buildBattleResult(run),score: run.score ?? bearScore(run.attacks)@@ -175,27 +163,38 @@/*** A resolved battle ready to run many times with different seeds. When battle-start is deterministic,* the entire pre-loop runtime (`template`) is seed-independent and built once; each run clones its- * mutable state and reuses the skills + static profile by reference. For stochastic battle-start the- * template is absent and runs reuse only the resolved fighters.+ * mutable state and reuses the skills + static profile by reference. For stochastic battle-start,+ * the template is absent; resolved fighters, skills, and the static profile are still reused while+ * chance-bearing setup state is rebuilt per run.*/export interface CompiledBattle {input: BattleInput;config: SimulatorConfig;fighters: Record<SideId, ResolvedFighter>;template?: Runtime;+ staticProfile: StaticDamageProfile;+ runtimeSkills: RuntimeSkills;deterministicBattleStart: boolean;}export function prepareBattle(input: BattleInput, config: SimulatorConfig): CompiledBattle {const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const deterministicBattleStart = !buildRuntimeSkills([attacker, defender]).battleStart.some(hasChanceTrigger);- // Deterministic battle-start => the entire pre-loop runtime is seed-independent; build it once.- const template = deterministicBattleStart ? setupRuntime(fighters, input, "simulator-prepare") : undefined;- return { input, config, fighters, template, deterministicBattleStart };+ const runtimeSkills = buildRuntimeSkills([attacker, defender]);+ const deterministicBattleStart = !runtimeSkills.battleStart.some(hasChanceTrigger);+ if (deterministicBattleStart) {+ // The entire pre-loop runtime is seed-independent, so build it once and clone it per run.+ const template = setupRuntime(fighters, input, "simulator-prepare", NULL_RECORDER, runtimeSkills);+ return { input, config, fighters, template, staticProfile: template.staticDamageProfile!, runtimeSkills, deterministicBattleStart };+ }+ // Chance-bearing battle-start effects must be rolled per run. Build only the deterministic+ // setup effects that feed static buckets here; do not execute and discard the stochastic setup.+ const staticEffects = materializeStaticSetupEffects(fighters, input, runtimeSkills);+ const staticProfile = buildStaticDamageProfile(fighters, staticEffects);+ return { input, config, fighters, staticProfile, runtimeSkills, deterministicBattleStart };}export function runPrepared(compiled: CompiledBattle, seed?: string | number, options: SimulationOptions = {}): BattleResult {// Only override the compiled input's seed when a seed is explicitly supplied; spreading an@@ -203,19 +202,37 @@const runInput = seed === undefined ? compiled.input : { ...compiled.input, seed };return buildBattleResult(runBattle(runInput, compiled.config, options, compiled));}+/**+ * Prepare the battle once and run `count` replicates, reusing the compiled fighters, skills, and+ * static damage profile across every run. Replicate 0 runs with the input's own seed; replicate+ * `i` runs with `${seed}#${i}` so results are reproducible and distinct.+ */+export function simulateBattles(+ input: BattleInput,+ config: SimulatorConfig,+ options: SimulationOptions & { count?: number } = {}+): BattleResult[] {+ const { count = 1, ...runOptions } = options;+ if (!Number.isInteger(count) || count < 1) {+ throw new Error(`simulateBattles count must be a positive integer, got ${JSON.stringify(count)}`);+ }+ const compiled = prepareBattle(input, config);+ const baseSeed = input.seed ?? "simulator-default";+ return Array.from({ length: count }, (_, index) =>+ runPrepared(compiled, index === 0 ? baseSeed : `${baseSeed}#${index}`, runOptions)+ );+}+function buildBattleResult(run: BattleRun): BattleResult {const { fighters, runtime } = run;return {winner: run.winner,rounds: run.rounds,remaining: { attacker: ceilTroops(fighters.attacker.troops), defender: ceilTroops(fighters.defender.troops) },attacks: run.attacks,- skillReport: {- attacker: [...runtime.skillReports.attacker.values()],- defender: [...runtime.skillReports.defender.values()]- },+ skillReport: run.skillReport,resolved: buildResolved(fighters.attacker, fighters.defender),effectActivationCounts: runtime.effectActivationCounts,extraSkillAttackJobsByEffect: runtime.extraSkillAttackJobsByEffect,attackControlCounts: runtime.attackControlCounts,@@ -230,25 +247,60 @@}// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage// profile. Static-phase effects never enter the per-job index.-function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number, collectSkillReports = true): Runtime {- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), collectSkillReports);+function setupRuntime(+ fighters: Record<SideId, ResolvedFighter>,+ input: BattleInput,+ seed: string | number,+ recorder: BattleRecorder,+ runtimeSkills?: RuntimeSkills,+ prebuiltProfile?: StaticDamageProfile+): Runtime {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), runtimeSkills);const setupEffects = [- ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),- ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),- ...addInputPassiveEffects(runtime, input.defender.passive, "defender")+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime, recorder),+ ...createInputPassiveEffects(input.attacker.passive, "attacker"),+ ...createInputPassiveEffects(input.defender.passive, "defender")];- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);+ // Effects feeding static buckets are validated deterministic, so the profile is seed-independent+ // and a prepared build can be reused even when other battle_start skills are stochastic.+ runtime.staticDamageProfile = prebuiltProfile ?? buildStaticDamageProfile(fighters, setupEffects);+ runtime.setupEffects = setupEffects;+ recorder.recordStaticProfile(fighters, setupEffects);runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}-// Clone a prepared template's mutable per-run effect graph. The template already owns the-// correct index topology and round schedules, so remap those references to the effect clones-// instead of classifying and scheduling every effect again. Skills and the immutable static-// profile are shared by reference.-function cloneRuntime(template: Runtime, rng: Rng, collectSkillReports = true): Runtime {+function materializeStaticSetupEffects(+ fighters: Record<SideId, ResolvedFighter>,+ input: BattleInput,+ runtimeSkills: RuntimeSkills+): ActiveEffect[] {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng("simulator-static-profile"), runtimeSkills);+ const staticSkills = runtimeSkills.battleStart.filter((skill) =>+ skill.effects.some((effect) => bucketDefinition(effect.type)?.phase === "static")+ );+ return [+ ...triggerSkills("battle_start", 0, staticSkills, runtime, NULL_RECORDER),+ ...createInputPassiveEffects(input.attacker.passive, "attacker"),+ ...createInputPassiveEffects(input.defender.passive, "defender")+ ];+}++// Deterministic battle-start effects are materialized once in a prepared runtime. Replay only+// their observation events for each run; combat state continues to come from the cloned template.+function recordPreparedBattleStartSkills(runtime: Runtime, recorder: BattleRecorder): void {+ for (const skill of runtime.skills.battleStart) {+ if (!skillMatchesTrigger(skill, "battle_start", 0)) continue;+ recorder.recordSkillTriggerAttempt(skill);+ if (!chancePasses(skill, runtime.rng)) continue;+ recorder.recordSkillTriggered(skill);+ for (const _effect of skill.effects) recorder.recordSkillEffectActivated(skill);+ }+}++function cloneRuntime(template: Runtime, rng: Rng): Runtime {const effectClones = new Map<ActiveEffect, ActiveEffect>();const preparedEffects = template.preparedEffects.map((effect) => {const clone = { ...effect };effectClones.set(effect, clone);@@ -258,20 +310,19 @@const clone = effectClones.get(effect);if (!clone) throw new Error(`prepared effect ${effect.id} is missing from the runtime template`);return clone;};-const runtime: Runtime = {- effectIndex: cloneEffectIndex(template.effectIndex, cloneEffect),+ effectIndex: cloneEffectIndex(template.effectIndex, preparedEffects, cloneEffect),preparedEffects,activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),usedEffects: [],staticDamageProfile: template.staticDamageProfile,- damageScratch: createFastDamageScratch(),+ setupEffects: template.setupEffects,+ damageScratch: createDamageScratch(),rng,skills: template.skills,- skillReports: collectSkillReports ? cloneSkillReports(template.skillReports) : { attacker: new Map(), defender: new Map() },effectActivationCounts: { ...template.effectActivationCounts },extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },attackControlCounts: { ...template.attackControlCounts },counters: {@@ -281,24 +332,12 @@};return runtime;}-function cloneEffectSchedule(- schedule: Array<ActiveEffect[] | undefined>,- cloneEffect: (effect: ActiveEffect) => ActiveEffect-): Array<ActiveEffect[] | undefined> {+function cloneEffectSchedule(schedule: Array<ActiveEffect[] | undefined>, cloneEffect: (effect: ActiveEffect) => ActiveEffect): Array<ActiveEffect[] | undefined> {return schedule.map((effects) => effects?.map(cloneEffect));}-function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {- const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {- const out = new Map<string, SkillReportEntry>();- for (const [key, entry] of side) out.set(key, { ...entry, unsupportedEffects: [...entry.unsupportedEffects] });- return out;- };- return { attacker: cloneSide(reports.attacker), defender: cloneSide(reports.defender) };-}-function configWithBearTroop(config: SimulatorConfig): SimulatorConfig {return {...config,troopStats: {@@ -324,45 +363,55 @@options: SimulationOptions,prepared?: CompiledBattle,loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }): BattleRun {- const collectSkillReports = (options.mode ?? "standard") !== "fast";if (prepared?.template) {const fighters: Record<SideId, ResolvedFighter> = {attacker: cloneFighterForRun(prepared.fighters.attacker),defender: cloneFighterForRun(prepared.fighters.defender)};- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"), collectSkillReports);- return runLoop(input, fighters, runtime, options, loopOptions);+ const recorder = recorderFor(options, fighters);+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ recorder.recordStaticProfile(fighters, runtime.setupEffects);+ recordPreparedBattleStartSkills(runtime, recorder);+ return runLoop(input, fighters, runtime, recorder, options, loopOptions);}const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", collectSkillReports);- return runLoop(input, fighters, runtime, options, loopOptions);+ const recorder = recorderFor(options, fighters);+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills, prepared?.staticProfile);+ return runLoop(input, fighters, runtime, recorder, options, loopOptions);}+function recorderFor(options: SimulationOptions, fighters: Record<SideId, ResolvedFighter>): BattleRecorder {+ return createRecorder(+ options.mode ?? "standard",+ [fighters.attacker, fighters.defender],+ () => buildResolved(fighters.attacker, fighters.defender)+ );+}+function runLoop(input: BattleInput,fighters: Record<SideId, ResolvedFighter>,runtime: Runtime,+ recorder: BattleRecorder,options: SimulationOptions,loopOptions: RunLoopOptions): BattleRun {- const mode = options.mode ?? "standard";- const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;const useEffectsOnCancel = {dodge: options.useEffectsOnDodge ?? true,no_attack: options.useEffectsOnNoAttack ?? true};+ if (!runtime.staticDamageProfile) throw new Error("runLoop requires a runtime with a static damage profile");const damageJobOptions = {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,+ recorder,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,+ scratch: runtime.damageScratch,+ capToTakerTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects};let rounds = 0;@@ -371,14 +420,11 @@if (winnerFor(fighters)) break;rounds = round;const roundStartTroops = snapshotTroops(fighters);processEffectSchedule(runtime, round);- triggerRoundStartSkills(round, runtime, roundStartTroops);+ triggerRoundStartSkills(round, runtime, roundStartTroops, recorder);- // Applied-effect events keyed by the intent they ordered.- const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;- const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);- const allJobs: DamageJob[] = []; // for recorder+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, recorder);const results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];// Phase 1: fire all attack_declared triggers for every intended attack before@@ -387,11 +433,11 @@const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[- damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)+ damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)] ?? [];- triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, intent);+ triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, recorder, intent);const job = normalJob(intent, roundStartTroops);declaredNormalJobs.push({ intent, job });}@@ -418,64 +464,66 @@chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);}cancelled.push({intent,- effectId: control.effect.id,- reason: control.reason,- appliedEffects: recorder.capturesAppliedEffects- ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))- : NO_APPLIED_EFFECTS+ control});continue;}- if (recorder.capturesTrace) allJobs.push(job);- const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);- if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);- if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {+ recorder.recordScheduledDamageJob(job);+ const normalResult = calculateDamageJob(job, fighters, damageJobOptions);+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage, recorder);+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {score += normalResult.kills;}chargeUsedEffects(runtime);- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder);const processedExtraEffectIds = new Set<string>();const processedExtraJobIds = new Set<string>();const normalEntry: DamageJobResult = {job,- result: normalResult,- extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), undefined)+ result: normalResult};results.push(normalEntry);for (const extraJob of extraSkill.jobs) {if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;- if (recorder.capturesTrace) allJobs.push(extraJob);- const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);- if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);+ recorder.recordScheduledDamageJob(extraJob);+ const extraResult = calculateDamageJob(extraJob, fighters, damageJobOptions);+ if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage, recorder);processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");processedExtraJobIds.add(extraJob.id);if (extraJob.sourceEffectId) {runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;}results.push({ job: extraJob, result: extraResult });- if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {+ if (loopOptions.scoreSide && extraJob.dealerSide === loopOptions.scoreSide.dealerSide && extraJob.takerSide === loopOptions.scoreSide.takerSide) {score += extraResult.kills;}chargeUsedEffects(runtime);}- normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));for (const usedEffectGroup of extraSkill.usedEffectGroups) {if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);+ recorder.recordExtraAttack(+ job,+ usedEffectGroup.selected,+ extraSkill.jobs,+ usedEffectGroup.firstJobIndex,+ usedEffectGroup.jobCount,+ processedExtraJobIds+ );}}if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);else commitRoundCounters(cancelled, results, runtime);- for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.extraAppliedEffects);- recorder.recordRound(round, roundStartTroops, intents, allJobs);+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.control.effect, entry.control.reason);+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);+ recorder.recordRound(round, roundStartTroops, intents);}const winner = winnerFor(fighters) ?? "draw";return {@@ -484,77 +532,50 @@winner,rounds,attacks: recorder.attacks,trace: recorder.trace,+ skillReport: recorder.skillReport,score};}interface CancelledAttack {intent: AttackIntent;- effectId: string;- reason: "dodge" | "no_attack";- appliedEffects: AppliedEffect[];+ control: Control;}interface DamageJobResult {job: DamageJob;result: DamageResult;- extraAppliedEffects?: AppliedEffect[];}-const NO_APPLIED_EFFECTS: AppliedEffect[] = [];--function appendedEvent(order: AppliedOrderEffect | undefined, event: AppliedEffect): AppliedEffect[] {- return order ? [order, event] : [event];-}--function appendedEvents(order: AppliedOrderEffect | undefined, events: AppliedEffect[] | undefined): AppliedEffect[] | undefined {- if (!order) return events;- return events ? [order, ...events] : [order];-}--function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {- return {- activeEffectId: effect.id,- effectId: effect.source.effectId ?? effect.id,- source: sourceLabel(effect),- sourceSide: effect.ownerSide- };-}--function appliedControlEvent(control: Control): AppliedControlEffect {- return { kind: "control", ...appliedEffectBase(control.effect), reason: control.reason };-}-function triggerRoundStartSkills(round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ recorder: BattleRecorder): ActiveEffect[] {const activated: ActiveEffect[] = [];- activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime));+ activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime, recorder));for (const skill of runtime.skills.roundStartPerUnit) {const side = roundTriggerSourceSide(skill);- const defenderSide = roundTriggerTargetSide(skill, side);+ const takerSide = roundTriggerTargetSide(skill, side);if (!skillMatchesTrigger(skill, "round_start", round)) continue;- const reports = runtime.skillReports[skill.side];- const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));- if (report) report.triggersSeen += 1;+ recorder.recordSkillTriggerAttempt(skill);if (!chancePasses(skill, runtime.rng)) continue;- if (report) report.skillActivations += 1;+ recorder.recordSkillTriggered(skill);let orderIndex = 0;- for (const attackerUnit of roundTriggerUnits(skill, roundStartTroops)) {- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);- if (!defenderUnit) continue;- const intent = syntheticRoundIntent(round, side, attackerUnit, defenderSide, defenderUnit, orderIndex, runtime);+ for (const dealerUnit of roundTriggerUnits(skill, roundStartTroops)) {+ const takerUnit = chooseTakerUnit(dealerUnit, side, takerSide, roundStartTroops, runtime.effectIndex);+ if (!takerUnit) continue;+ const intent = syntheticRoundIntent(round, side, dealerUnit, takerSide, takerUnit, orderIndex, runtime);if (!skillMatchesTrigger(skill, "round_start", round, intent)) continue;for (const effectIntent of skill.effects) {const effect = activateEffect(skill, effectIntent, round, intent);addActiveEffect(runtime, effect);activated.push(effect);runtime.effectActivationCounts[skill.side] += 1;- if (report) report.effectActivations += 1;+ recorder.recordSkillEffectActivated(skill);}orderIndex += 1;}}@@ -580,28 +601,28 @@}function syntheticRoundIntent(round: number,- attackerSide: SideId,- attackerUnit: UnitType,- defenderSide: SideId,- defenderUnit: UnitType,+ dealerSide: SideId,+ dealerUnit: UnitType,+ takerSide: SideId,+ takerUnit: UnitType,orderIndex: number,runtime: Runtime): AttackIntent {return {- id: `r${round}:${attackerSide}:${attackerUnit}:turn:${orderIndex}`,+ id: `r${round}:${dealerSide}:${dealerUnit}:turn:${orderIndex}`,round,source: "normal",- attackerSide,- attackerUnit,- defenderSide,- defenderUnit,+ dealerSide,+ dealerUnit,+ takerSide,+ takerUnit,orderIndex,- previousAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],- projectedAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],- previousReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit],- projectedReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit]+ previousAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],+ projectedAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],+ previousReceivedAttackCount: runtime.counters.received[takerSide][takerUnit],+ projectedReceivedAttackCount: runtime.counters.received[takerSide][takerUnit]};}function classifyRandomness(skills: ResolvedSkill[]): BattleRandomness {@@ -621,39 +642,24 @@const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng, collectSkillReports = true): Runtime {- const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };- const skills = buildRuntimeSkills(fighters);- for (const fighter of collectSkillReports ? fighters : []) {- for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {- reports[fighter.side].set(reportKey(skill), {- sourceKind: skill.sourceKind,- heroName: skill.heroName,- troopType: skill.troopType,- skillId: skill.id,- skillName: skill.name,- level: skill.level,- triggersSeen: 0,- skillActivations: 0,- effectActivations: 0,- skillKills: 0,- unsupportedEffects: []- });- }- }+function createRuntime(fighters: ResolvedFighter[], rng: Rng, preparedSkills?: RuntimeSkills): Runtime {+ const skills = preparedSkills ?? buildRuntimeSkills(fighters);return {- effectIndex: createEffectIndex(),+ effectIndex: createEffectIndex(+ skills.damageGroups,+ skills.damageGroupsByJobShape+ ),preparedEffects: [],activateEffectsByRound: [],expireEffectsByRound: [],usedEffects: [],staticDamageProfile: undefined,- damageScratch: createFastDamageScratch(),+ setupEffects: [],+ damageScratch: createDamageScratch(),rng,skills,- skillReports: reports,effectActivationCounts: { attacker: 0, defender: 0 },extraSkillAttackJobsByEffect: {},attackControlCounts: { dodge: 0, no_attack: 0 },counters: {@@ -665,19 +671,61 @@function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);for (const skill of all) compiledTriggerForSkill(skill);+ const damageGroups: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];+ const damageGroupsByJobShape: NonNullable<ActiveEffect["damageIndexGroup"]>[][] = Array.from({ length: 72 }, () => []);+ const plansByDefinition: Record<SideId, Map<object, DamageGroupPlan>> = {+ attacker: new Map(),+ defender: new Map()+ };+ for (const skill of all) {+ for (const intent of skill.effects) {+ const definition = bucketDefinition(intent.type);+ if (!definition || definition.valueType !== "pct" || definition.phase === "static") continue;+ const existingPlan = plansByDefinition[skill.side].get(intent.sourceDefinition);+ if (existingPlan) {+ assignDamageGroupPlan(intent, existingPlan);+ continue;+ }+ const groupsByScopeKey: Array<NonNullable<ActiveEffect["damageIndexGroup"]> | undefined> = [];+ const groupsForDefinition: NonNullable<ActiveEffect["damageIndexGroup"]>[] = [];+ const slotsForDefinition: Uint8Array[] = [];+ for (const attackIntent of potentialActivationIntents(skill)) {+ const candidate = activateEffect(skill, intent, 1, attackIntent);+ const scopeKey = resolvedEffectScopeKey(candidate.appliesTo, candidate.appliesVs);+ if (groupsByScopeKey[scopeKey]) continue;+ const slots = damageShapeSlotsForEffect(candidate, definition.path);+ if (slots.length === 0) continue;+ if (candidate.sameEffectStacking === "max") assertDisjointResolvedGroupSlots(intent.id, slots, slotsForDefinition);+ const group: NonNullable<ActiveEffect["damageIndexGroup"]> = {+ effects: [],+ bucketIndex: damageBucketIndex(definition.path),+ sameEffectStacking: candidate.sameEffectStacking+ };+ groupsByScopeKey[scopeKey] = group;+ groupsForDefinition.push(group);+ slotsForDefinition.push(slots);+ damageGroups.push(group);+ for (const slot of slots) damageGroupsByJobShape[slot].push(group);+ }+ if (groupsForDefinition.length === 0) throw new Error(`Runtime modifier ${intent.id} has no resolvable damage group`);+ const plan: DamageGroupPlan = groupsForDefinition.length === 1 ? { fixed: groupsForDefinition[0] } : { byScopeKey: groupsByScopeKey };+ plansByDefinition[skill.side].set(intent.sourceDefinition, plan);+ assignDamageGroupPlan(intent, plan);+ }+ }const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");const roundStart = all.filter((skill) => skill.trigger.type === "turn");const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });for (const skill of attackDeclared) {const trigger = compiledTriggerForSkill(skill);- for (const attackerUnit of unitsFromMask(trigger.source.units)) {- for (const defenderUnit of unitsFromMask(trigger.target.units)) {- const slot = damageJobShapeSlot("normal", trigger.source.side, attackerUnit, trigger.target.side, defenderUnit);- const skills = attackDeclaredByJobShape[slot];- if (skills) skills.push(skill);+ for (const dealerUnit of unitsFromMask(trigger.source.units)) {+ for (const takerUnit of unitsFromMask(trigger.target.units)) {+ const slot = damageJobShapeSlot("normal", trigger.source.side, dealerUnit, trigger.target.side, takerUnit);+ const matching = attackDeclaredByJobShape[slot];+ if (matching) matching.push(skill);else attackDeclaredByJobShape[slot] = [skill];}}}@@ -687,12 +735,65 @@roundStart,roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),attackDeclared,- attackDeclaredByJobShape+ attackDeclaredByJobShape,+ damageGroups,+ damageGroupsByJobShape};}+interface DamageGroupPlan {+ fixed?: NonNullable<ActiveEffect["damageIndexGroup"]>;+ byScopeKey?: Array<NonNullable<ActiveEffect["damageIndexGroup"]> | undefined>;+}++function assignDamageGroupPlan(intent: ResolvedEffectIntentDefinition, plan: DamageGroupPlan): void {+ intent.damageGroup = plan.fixed;+ intent.damageGroupsByScopeKey = plan.byScopeKey;+}++function assertDisjointResolvedGroupSlots(effectId: string, slots: Uint8Array, existingGroups: Iterable<Uint8Array>): void {+ for (const existing of existingGroups) {+ for (const slot of slots) {+ if (existing.includes(slot)) {+ throw new Error(`Resolved scopes for max-stacking effect ${effectId} overlap at damage job slot ${slot}`);+ }+ }+ }+}++function potentialActivationIntents(skill: ResolvedSkill): Array<AttackIntent | undefined> {+ if (skill.trigger.type === "battle_start" || (skill.trigger.type === "turn" && skill.trigger.source === undefined)) return [undefined];+ const trigger = compiledTriggerForSkill(skill);+ const dealerSide = trigger.source.side;+ let takerSide = trigger.target.side;+ if (skill.trigger.type === "turn" && takerSide === dealerSide) takerSide = oppositeSide(dealerSide);+ if (takerSide === dealerSide) return [];+ const dealerUnits = unitsFromMask(trigger.source.units);+ const takerUnits = unitsFromMask(trigger.target.units);+ const intents: AttackIntent[] = [];+ for (const dealerUnit of dealerUnits) {+ for (const takerUnit of takerUnits) {+ intents.push({+ id: "group-plan",+ round: 1,+ source: "normal",+ dealerSide,+ dealerUnit,+ takerSide,+ takerUnit,+ orderIndex: 0,+ previousAttackCount: 0,+ projectedAttackCount: 1,+ previousReceivedAttackCount: 0,+ projectedReceivedAttackCount: 1+ });+ }+ }+ return intents;+}+function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {if (!isRuntimeIndexableEffect(effect)) return;const window = effectRoundWindow(effect);if (!window) {@@ -730,9 +831,9 @@runtime.activateEffectsByRound[round] = undefined;}}-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {+function createInputPassiveEffects(passive: FighterInput["passive"], side: SideId): ActiveEffect[] {const effects: ActiveEffect[] = [];if (!passive) return effects;for (const stat of ["attack", "defense", "lethality", "health"] as const) {for (const direction of ["up", "down"] as const) {@@ -764,9 +865,8 @@remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};- addActiveEffect(runtime, effect);effects.push(effect);}}return effects;@@ -776,24 +876,23 @@triggerType: "battle_start" | "round_start" | "attack_declared",round: number,skills: ResolvedSkill[],runtime: Runtime,+ recorder: BattleRecorder,intent?: AttackIntent): ActiveEffect[] {const activated: ActiveEffect[] = [];for (const skill of skills) {if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;- const reports = runtime.skillReports[skill.side];- const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));- if (report) report.triggersSeen += 1;+ recorder.recordSkillTriggerAttempt(skill);if (!chancePasses(skill, runtime.rng)) continue;- if (report) report.skillActivations += 1;+ recorder.recordSkillTriggered(skill);for (const effectIntent of skill.effects) {const effect = activateEffect(skill, effectIntent, round, intent);addActiveEffect(runtime, effect);activated.push(effect);runtime.effectActivationCounts[skill.side] += 1;- if (report) report.effectActivations += 1;+ recorder.recordSkillEffectActivated(skill);}}return activated;}@@ -801,34 +900,34 @@function resolveAttackIntents(round: number,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"],- orderEvents?: Map<string, AppliedOrderEffect>+ recorder: BattleRecorder): AttackIntent[] {const intents: AttackIntent[] = [];for (const side of ["attacker", "defender"] as SideId[]) {- const defenderSide = oppositeSide(side);+ const takerSide = oppositeSide(side);let orderIndex = 0;- for (const attackerUnit of UNIT_TYPES) {- if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);- const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);- if (!defenderUnit) continue;- const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;+ for (const dealerUnit of UNIT_TYPES) {+ if ((roundStartTroops[side][dealerUnit] ?? 0) <= 0) continue;+ const ordered = orderFromEffects(dealerUnit, side, runtime.effectIndex, true);+ const takerUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, takerSide, roundStartTroops);+ if (!takerUnit) continue;+ const intentId = `r${round}:${side}:${dealerUnit}:${orderIndex}`;if (ordered) {chargeEffectUse(runtime, ordered.effect);- orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });+ recorder.recordBattleOrder(intentId, ordered.effect, takerUnit);}- const previousAttackCount = runtime.counters.attacks[side][attackerUnit];- const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];+ const previousAttackCount = runtime.counters.attacks[side][dealerUnit];+ const previousReceivedAttackCount = runtime.counters.received[takerSide][takerUnit];intents.push({id: intentId,round,source: "normal",- attackerSide: side,- attackerUnit,- defenderSide,- defenderUnit,+ dealerSide: side,+ dealerUnit,+ takerSide,+ takerUnit,orderIndex,previousAttackCount,projectedAttackCount: previousAttackCount + 1,previousReceivedAttackCount,@@ -841,33 +940,32 @@}// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but// does not charge them: no real attack intent is being ordered.-function chooseDefenderUnit(- attackerUnit: UnitType,- attackerSide: SideId,- defenderSide: SideId,+function chooseTakerUnit(+ dealerUnit: UnitType,+ dealerSide: SideId,+ takerSide: SideId,roundStartTroops: DamageJob["roundStartTroops"],- effectIndex: EffectIndex,- round: number+ effectIndex: EffectIndex): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;- return firstLivingUnit(order, defenderSide, roundStartTroops);+ const order = orderFromEffects(dealerUnit, dealerSide, effectIndex, false)?.order ?? UNIT_TYPES;+ return firstLivingUnit(order, takerSide, roundStartTroops);}function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);}function orderFromEffects(- attackerUnit: UnitType,- attackerSide: SideId,+ dealerUnit: UnitType,+ dealerSide: SideId,index: EffectIndex,advanceAttackDelay: boolean): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {if (effect.intent.type !== "attack_order") continue;- if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;+ if (effect.appliesTo.side !== dealerSide || !unitMaskHas(effect.appliesTo.units, dealerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };@@ -917,12 +1015,12 @@round: intent.round,kind: "normal",sourceIntentId: intent.id,roundStartTroops,- attackerSide: intent.attackerSide,- attackerUnit: intent.attackerUnit,- defenderSide: intent.defenderSide,- defenderUnit: intent.defenderUnit,+ dealerSide: intent.dealerSide,+ dealerUnit: intent.dealerUnit,+ takerSide: intent.takerSide,+ takerUnit: intent.takerUnit,sourceMultiplier: 1};}@@ -930,13 +1028,12 @@normalAttack: DamageJob,round: number,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"],- capturesTrace: boolean+ recorder: BattleRecorder): ExtraSkillJobsResult {const jobs: DamageJob[] = [];const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];- let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)),@@ -952,45 +1049,44 @@const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);if (multiplier <= 0) continue;- const sourceSkillReportKey = reportKeyForEffectSource(effect.source);for (const source of sources) {if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;for (const target of targets) {if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;- jobs.push({+ const job: DamageJob = {id: `${normalAttack.sourceIntentId}:skill:${sourceEffectId}:${definitionIndex}:${jobs.length}`,round,kind: "skill",sourceIntentId: normalAttack.sourceIntentId,roundStartTroops,- attackerSide: source.side,- attackerUnit: source.unit,- defenderSide: target.side,- defenderUnit: target.unit,+ dealerSide: source.side,+ dealerUnit: source.unit,+ takerSide: target.side,+ takerUnit: target.unit,sourceEffectId,- sourceSkillReportKey,sourceMultiplier: multiplier- });+ };+ jobs.push(job);+ recorder.recordSkillDamageJob(job, effect);}}definitionIndex += 1;}if (jobs.length > firstJobIndex) {// The whole stacking group is charged whenever the selected effect spawned jobs,// regardless of duration constraints (extra attacks always deplete per firing).- usedEffectGroups.push({ sourceEffectId, effects: effectGroup.effects });- if (capturesTrace) {- (appliedEffects ??= []).push({- kind: "extra_attack",- ...appliedEffectBase(effect),- spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)- });- }+ usedEffectGroups.push({+ sourceEffectId,+ selected: effect,+ effects: effectGroup.effects,+ firstJobIndex,+ jobCount: jobs.length - firstJobIndex+ });}}- return { jobs, usedEffectGroups, appliedEffects };+ return { jobs, usedEffectGroups };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {const selected: ExtraAttackEffectGroup[] = [];@@ -1014,12 +1110,12 @@}function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {return (- effect.appliesTo.side === normalAttack.attackerSide &&- unitMaskHas(effect.appliesTo.units, normalAttack.attackerUnit) &&- effect.appliesVs.side === normalAttack.defenderSide &&- unitMaskHas(effect.appliesVs.units, normalAttack.defenderUnit)+ effect.appliesTo.side === normalAttack.dealerSide &&+ unitMaskHas(effect.appliesTo.units, normalAttack.dealerUnit) &&+ effect.appliesVs.side === normalAttack.takerSide &&+ unitMaskHas(effect.appliesVs.units, normalAttack.takerUnit));}interface TriggerJobUnit {@@ -1033,19 +1129,19 @@effect: ActiveEffect,normalAttack: DamageJob,roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {- if (selector === "use.source") return [{ side: normalAttack.attackerSide, unit: normalAttack.attackerUnit }];- if (selector === "use.target") return [{ side: normalAttack.defenderSide, unit: normalAttack.defenderUnit }];+ if (selector === "use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];+ if (selector === "use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));- if (selector === "enemy.living") return livingUnits(normalAttack.defenderSide, roundStartTroops);- if (selector === "self.living") return livingUnits(normalAttack.attackerSide, roundStartTroops);+ if (selector === "enemy.living") return livingUnits(normalAttack.takerSide, roundStartTroops);+ if (selector === "self.living") return livingUnits(normalAttack.dealerSide, roundStartTroops);const units = unitListFromSelector(selector);if (!units) {throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);}- const fallbackSide = role === "source" ? normalAttack.attackerSide : normalAttack.defenderSide;+ const fallbackSide = role === "source" ? normalAttack.dealerSide : normalAttack.takerSide;return units.map((unit) => ({ side: fallbackSide, unit }));}function livingUnits(side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {@@ -1084,47 +1180,35 @@job: DamageJob,roundStartTroops: DamageJob["roundStartTroops"],roundTargetDamage: Record<SideId, Record<UnitType, number>>): boolean {- const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);- return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);+ return available <= 0 || roundTargetDamage[job.takerSide][job.takerUnit] >= available;}function capJobToRemainingTarget(result: DamageResult,job: DamageJob,roundStartTroops: DamageJob["roundStartTroops"],- roundTargetDamage: Record<SideId, Record<UnitType, number>>+ roundTargetDamage: Record<SideId, Record<UnitType, number>>,+ recorder: BattleRecorder): void {- const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);- const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);+ const alreadyDamaged = roundTargetDamage[job.takerSide][job.takerUnit];const remaining = Math.max(0, available - alreadyDamaged);result.kills = Math.min(result.kills, remaining);- roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;- if (result.trace) result.trace.finalKills = result.kills;+ roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;+ recorder.recordFinalKills(result);}-function filterExtraAppliedEffects(- events: AppliedExtraAttackEffect[] | undefined,- processedExtraJobIds: Set<string>-): AppliedExtraAttackEffect[] | undefined {- if (!events) return undefined;- const filtered: AppliedExtraAttackEffect[] = [];- for (const event of events) {- const spawnedJobIds = event.spawnedJobIds.filter((jobId) => processedExtraJobIds.has(jobId));- if (spawnedJobIds.length > 0) filtered.push({ ...event, spawnedJobIds });- }- return filtered.length > 0 ? filtered : undefined;-}-// Apply the round's effects to fighter state: remove killed troops and bump attack/received// counters. Every declared attack (each damage job and each cancelled attack) counts as one// attack for its attacker unit and one received for its defender unit.function commitRound(cancelled: CancelledAttack[], results: DamageJobResult[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {commitRoundCounters(cancelled, results, runtime);const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };for (const { job, result } of results) {- losses[job.defenderSide][job.defenderUnit] += result.kills;+ losses[job.takerSide][job.takerUnit] += result.kills;}for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {fighters[side].troops[unit] = Math.max(0, fighters[side].troops[unit] - losses[side][unit]);@@ -1133,14 +1217,14 @@}function commitRoundCounters(cancelled: CancelledAttack[], results: DamageJobResult[], runtime: Runtime): void {for (const entry of cancelled) {- runtime.counters.attacks[entry.intent.attackerSide][entry.intent.attackerUnit] += 1;- runtime.counters.received[entry.intent.defenderSide][entry.intent.defenderUnit] += 1;+ runtime.counters.attacks[entry.intent.dealerSide][entry.intent.dealerUnit] += 1;+ runtime.counters.received[entry.intent.takerSide][entry.intent.takerUnit] += 1;}for (const { job, result } of results) {- runtime.counters.attacks[job.attackerSide][job.attackerUnit] += 1;- runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;+ runtime.counters.attacks[job.dealerSide][job.dealerUnit] += 1;+ runtime.counters.received[job.takerSide][job.takerUnit] += 1;}}// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in@@ -1170,12 +1254,13 @@controlEffects: ActiveEffect[],runtime: Runtime): void {const used = runtime.usedEffects;- for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {- if (effect.expired) continue;- if (!advanceEffectAttackDelay(effect)) continue;- if (hasAttackDurationConstraint(effect)) used.push(effect);+ for (const group of runtime.effectIndex.damageGroupsByJobShape[damageJobSlot(job)]) {+ for (const effect of group.effects) {+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) used.push(effect);+ }}for (const effect of controlEffects) used.push(effect);if (!controlEffects.includes(winningControl)) used.push(winningControl);chargeUsedEffects(runtime);@@ -1198,9 +1283,9 @@}function bearScore(attacks: AttackOutcome[]): number {return attacks- .filter((attack) => attack.attackerSide === "attacker" && attack.defenderSide === "defender")+ .filter((attack) => attack.dealerSide === "attacker" && attack.takerSide === "defender").reduce((sum, attack) => sum + attack.kills, 0);}function total(troops: Record<UnitType, number>): number {@@ -1233,21 +1318,4 @@diagnostics: defender.diagnostics}};}--function reportKey(skill: ResolvedSkill): string {- const cached = REPORT_KEY_CACHE.get(skill);- if (cached) return cached;- const key = reportKeyFromParts(skill.sourceKind, skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "", skill.id);- REPORT_KEY_CACHE.set(skill, key);- return key;-}--function reportKeyForEffectSource(source: EffectSource): string | undefined {- if ((source.kind !== "hero_skill" && source.kind !== "troop_skill") || !source.skillId) return undefined;- return reportKeyFromParts(source.kind, source.heroInstanceId ?? source.heroName ?? source.troopType ?? "", source.skillId);-}--function reportKeyFromParts(sourceKind: SkillReportEntry["sourceKind"], sourceKey: string, skillId: string): string {- return `${sourceKind}:${sourceKey}:${skillId}`;-}Index: simulator/src/staticDamageProfile.ts===================================================================--- simulator/src/staticDamageProfile.ts prev run+++ simulator/src/staticDamageProfile.ts this run@@ -1,286 +1,157 @@-import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";+import type { ActiveEffect, DamageBucketTrace, ResolvedFighter, SideId, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";-import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";-import { sourceLabel } from "./effects";+import {+ BUCKET_DEFINITIONS,+ isPassiveBucket,+ passiveBucketRole,+ type BucketRole,+ type PassiveBucket,+ type StaticPlayerBucket+} from "./damageBuckets";+import {+ applyStaticDamageBucketValue,+ createStaticDamageBucketFactors,+ evaluateDamageExpression,+ staticDamageBucketSet,+ type StaticDamageBucketMatrix,+ type StaticDamageBucketFactors,+ type StaticDamageProfile,+ type StaticDamageProfileEntry+} from "./damage";-export interface StaticDamageProfileTerm {- raw?: number;- totalPct?: number;- contributors: DamageBucketTrace["contributors"];-}+export type { StaticDamageBucketMatrix, StaticDamageProfile } from "./damage";-export interface StaticDamageProfileEntry {- factor: number;- playerFactorsValid: boolean;- buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;-}+/** A player stat bonus produced a non-positive factor while building the numeric profile. */+export class DamageAggregationError extends Error {+ readonly groupId: string;+ readonly netPct: number;+ readonly factor: number;+ readonly contributors: DamageBucketTrace["contributors"];-export interface StaticDamageProfile {- offense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;- defense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;+ constructor(args: { groupId: string; netPct: number; factor: number; contributors: DamageBucketTrace["contributors"] }) {+ super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor} netPct=${args.netPct}`);+ this.name = "DamageAggregationError";+ this.groupId = args.groupId;+ this.netPct = args.netPct;+ this.factor = args.factor;+ this.contributors = args.contributors;+ }}-interface PassiveCandidate {+/**+ * One passive effect selected to contribute to a static bucket for a concrete side and unit.+ * Selection (including same-effect max stacking) lives here so the numeric profile and any+ * recorder-side description of it can never disagree.+ */+export interface PassiveContribution {+ role: BucketRole;+ side: SideId;+ unit: UnitType;+ bucket: PassiveBucket;effect: ActiveEffect;- bucket: StaticPassiveBucket;valuePct: number;}-interface PassiveCandidateGroup {- selected: PassiveCandidate;- candidates: PassiveCandidate[];- entry: StaticDamageProfileEntry;+export function buildStaticDamageBucketFactors(+ fighters: Record<SideId, ResolvedFighter>,+ activeEffects: ActiveEffect[]+): StaticDamageBucketMatrix {+ const factors: Record<SideId, Record<UnitType, StaticDamageBucketFactors>> = {+ attacker: buildSideFactors(fighters.attacker),+ defender: buildSideFactors(fighters.defender)+ };+ for (const contribution of selectPassiveContributions(activeEffects)) {+ applyStaticDamageBucketValue(+ factors[contribution.side][contribution.unit],+ contribution.bucket,+ contribution.valuePct+ );+ }+ return factors;}-export const STATIC_RAW_BUCKETS = ["troops.baseAttack", "troops.baseLethality", "troops.baseHealth", "troops.baseDefense"] as const;-export const STATIC_PLAYER_BUCKETS = ["player.attack", "player.lethality", "player.health", "player.defense"] as const;-export const STATIC_PASSIVE_BUCKETS = [- "passive.attack.up",- "passive.attack.down",- "passive.lethality.up",- "passive.lethality.down",- "passive.health.up",- "passive.health.down",- "passive.defense.up",- "passive.defense.down"-] as const;--export type StaticRawBucket = (typeof STATIC_RAW_BUCKETS)[number];-export type StaticPlayerBucket = (typeof STATIC_PLAYER_BUCKETS)[number];-export type StaticPassiveBucket = (typeof STATIC_PASSIVE_BUCKETS)[number];-export type StaticDamageBucket = StaticRawBucket | StaticPlayerBucket | StaticPassiveBucket;--// Roles are owned by the BUCKETS tree (single source of truth); derive, do not redeclare.-const STATIC_PASSIVE_BUCKET_ROLES = Object.fromEntries(- STATIC_PASSIVE_BUCKETS.map((bucket) => [bucket, bucketDefinition(bucket)!.role])-) as Record<StaticPassiveBucket, BucketRole>;-export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {- const profile: StaticDamageProfile = {- offense: {- attacker: buildSideEntries(fighters.attacker, "offense"),- defender: buildSideEntries(fighters.defender, "offense")- },- defense: {- attacker: buildSideEntries(fighters.attacker, "defense"),- defender: buildSideEntries(fighters.defender, "defense")- }+ const factors = buildStaticDamageBucketFactors(fighters, activeEffects);+ return {+ attacker: buildProfileSide(factors.attacker),+ defender: buildProfileSide(factors.defender)};-- applyStaticPassives(profile, activeEffects);- recomputeFactors(profile);- return profile;}-export function assertStaticPassiveEffectDefinition(- trigger: SkillFile["skills"][string]["trigger"],- effect: EffectIntentDefinition,- file: string,- skillId: string,- effectId: string-): void {- if (!isPassiveBucket(effect.type)) return;-- const path = `${file}:${skillId}.${effectId}`;- if (trigger.type !== "battle_start") {- throw new Error(`passive effect ${effect.type} must use battle_start trigger at ${path}`);- }- if (effect.value_evolution !== undefined) {- throw new Error(`passive effect ${effect.type} cannot define value_evolution at ${path}; passive effects must be static`);- }-- const duration = effect.duration;- if (duration === undefined) return;- if (duration.turns !== undefined || duration.attacks !== undefined) {- throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);- }+function buildProfileSide(factors: Record<UnitType, StaticDamageBucketFactors>): Record<UnitType, StaticDamageProfileEntry> {+ return Object.fromEntries(+ UNIT_TYPES.map((unit) => [unit, {+ dealerFactor: evaluateDamageExpression(staticDamageBucketSet(factors[unit], "dealer")),+ takerFactor: evaluateDamageExpression(staticDamageBucketSet(factors[unit], "taker"))+ }])+ ) as Record<UnitType, StaticDamageProfileEntry>;}-export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {- return (STATIC_BUCKET_SET as Set<string>).has(bucket);-}--export function isPassiveBucket(bucket: string): bucket is StaticPassiveBucket {- return (STATIC_PASSIVE_BUCKET_SET as Set<string>).has(bucket);-}--export function staticPassiveBucketRole(bucket: string): BucketRole | undefined {- return isPassiveBucket(bucket) ? STATIC_PASSIVE_BUCKET_ROLES[bucket] : undefined;-}--function buildSideEntries(fighter: ResolvedFighter, role: "offense" | "defense"): Record<UnitType, StaticDamageProfileEntry> {- return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildEntry(fighter, unit, role)])) as Record<UnitType, StaticDamageProfileEntry>;-}--function buildEntry(fighter: ResolvedFighter, unit: UnitType, role: "offense" | "defense"): StaticDamageProfileEntry {- const stats = fighter.troopDetails[unit]?.stats ?? fallbackStats();- const bonuses = fighter.statBonuses[unit] ?? emptyStats();- const buckets: StaticDamageProfileEntry["buckets"] = {};- if (role === "offense") {- setRaw(buckets, "troops.baseAttack", stats.attack);- setRaw(buckets, "troops.baseLethality", stats.lethality);- setPct(buckets, "player.attack", bonuses.attack, [{ effectId: "input:attack", source: "input_stats", valuePct: bonuses.attack, bucket: "player.attack" }]);- setPct(buckets, "player.lethality", bonuses.lethality, [- { effectId: "input:lethality", source: "input_stats", valuePct: bonuses.lethality, bucket: "player.lethality" }- ]);- } else {- setRaw(buckets, "troops.baseHealth", stats.health);- setRaw(buckets, "troops.baseDefense", stats.defense);- setPct(buckets, "player.health", bonuses.health, [{ effectId: "input:health", source: "input_stats", valuePct: bonuses.health, bucket: "player.health" }]);- setPct(buckets, "player.defense", bonuses.defense, [- { effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }- ]);+function applyPlayerBucket(factors: StaticDamageBucketFactors, bucket: StaticPlayerBucket, totalPct: number): void {+ const factor = applyStaticDamageBucketValue(factors, bucket, totalPct);+ if (factor <= 0) {+ const stat = bucket.slice("player.".length);+ throw new DamageAggregationError({+ groupId: `player.${BUCKET_DEFINITIONS[bucket].role}.${stat}`,+ netPct: totalPct,+ factor,+ contributors: [{ effectId: `input:${stat}`, source: "input_stats", valuePct: totalPct, bucket }]+ });}- return { factor: 1, playerFactorsValid: true, buckets };}-function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {- const groups = new Map<string, PassiveCandidateGroup>();+export function selectPassiveContributions(activeEffects: ActiveEffect[]): PassiveContribution[] {+ const contributions: PassiveContribution[] = [];+ const maxGroups = new Map<string, PassiveContribution>();for (const effect of activeEffects) {const bucket = effect.intent.type;if (!isPassiveBucket(bucket)) continue;- const role = staticPassiveBucketRole(bucket)!;- const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];+ const role = passiveBucketRole(bucket)!;+ const side = effect.appliesTo.side;for (const unit of UNIT_TYPES) {if (!unitMaskHas(effect.appliesTo.units, unit)) continue;- const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };+ const contribution: PassiveContribution = { role, side, unit, bucket, effect, valuePct: effect.getCurrentValuePct(1) };if (effect.sameEffectStacking === "max" && effect.stackingKey) {- const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;- const group = groups.get(key);- if (group) {- group.candidates.push(candidate);- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;- } else {- groups.set(key, { selected: candidate, candidates: [candidate], entry: targetEntries[unit] });- }+ const key = `${role}:${side}:${unit}:${bucket}:${effect.stackingKey}`;+ const selected = maxGroups.get(key);+ if (!selected || contribution.valuePct > selected.valuePct) maxGroups.set(key, contribution);} else {- addPassiveCandidate(targetEntries[unit], candidate);+ contributions.push(contribution);}}}- for (const group of groups.values()) {- addPassiveCandidate(group.entry, group.selected);- }+ contributions.push(...maxGroups.values());+ return contributions;}-function addPassiveCandidate(entry: StaticDamageProfileEntry, candidate: PassiveCandidate): void {- addPct(entry.buckets, candidate.bucket, candidate.valuePct, {- effectId: candidate.effect.source.effectId ?? candidate.effect.id,- source: sourceLabel(candidate.effect),- sourceSide: candidate.effect.ownerSide,- valuePct: candidate.valuePct,- bucket: candidate.bucket,- stackingKey: candidate.effect.stackingKey,- sameEffectStacking: candidate.effect.sameEffectStacking- });+function buildSideFactors(fighter: ResolvedFighter): Record<UnitType, StaticDamageBucketFactors> {+ return Object.fromEntries(+ UNIT_TYPES.map((unit) => {+ const factors = createStaticDamageBucketFactors();+ const stats = unitBaseStats(fighter, unit);+ const bonuses = unitPlayerBonuses(fighter, unit);+ applyStaticDamageBucketValue(factors, "troops.baseAttack", stats.attack);+ applyStaticDamageBucketValue(factors, "troops.baseLethality", stats.lethality);+ applyStaticDamageBucketValue(factors, "troops.baseHealth", stats.health);+ applyStaticDamageBucketValue(factors, "troops.baseDefense", stats.defense);+ applyPlayerBucket(factors, "player.attack", bonuses.attack);+ applyPlayerBucket(factors, "player.lethality", bonuses.lethality);+ applyPlayerBucket(factors, "player.health", bonuses.health);+ applyPlayerBucket(factors, "player.defense", bonuses.defense);+ return [unit, factors];+ })+ ) as Record<UnitType, StaticDamageBucketFactors>;}-function recomputeFactors(profile: StaticDamageProfile): void {- for (const side of ["attacker", "defender"] as SideId[]) {- for (const unit of UNIT_TYPES) {- const offense = profile.offense[side][unit];- const defense = profile.defense[side][unit];- offense.factor = offenseFactor(offense);- defense.factor = defenseFactor(defense);- offense.playerFactorsValid = playerPctFactorValid(offense, "player.attack") && playerPctFactorValid(offense, "player.lethality");- defense.playerFactorsValid = playerPctFactorValid(defense, "player.health") && playerPctFactorValid(defense, "player.defense");- }- }+/** Base stats a static entry reads for a unit; units with no troops resolve to neutral 1s. */+export function unitBaseStats(fighter: ResolvedFighter, unit: UnitType): StatBlock {+ return fighter.troopDetails[unit]?.stats ?? FALLBACK_STATS;}-function playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;+/** Player stat bonuses a static entry reads for a unit; missing units contribute nothing. */+export function unitPlayerBonuses(fighter: ResolvedFighter, unit: UnitType): StatBlock {+ return fighter.statBonuses[unit] ?? EMPTY_STATS;}-interface StaticFactorTerm {- bucket: StaticDamageBucket;- valueType: BucketValueType;- placement: BucketPlacement;-}--// The closed-pool aggregation is driven entirely by BUCKETS-tree metadata (role/valueType/-// placement); there is no second hand-written damage equation. Iteration follows tree-// insertion order, which fixes the numerator/denominator float association — do not reorder-// the static families in the tree without re-validating the parity gate.-const STATIC_FACTOR_TERMS_BY_ROLE: Record<BucketRole, StaticFactorTerm[]> = { attacker: [], defender: [] };-for (const path of Object.keys(BUCKET_DEFINITIONS)) {- const definition = BUCKET_DEFINITIONS[path];- if (definition.phase !== "static") continue;- STATIC_FACTOR_TERMS_BY_ROLE[definition.role].push({- bucket: path as StaticDamageBucket,- valueType: definition.valueType,- placement: definition.placement- });-}--function roleFactor(entry: StaticDamageProfileEntry, terms: StaticFactorTerm[]): number {- let numerator = 1;- let denominator = 1;- for (const term of terms) {- const value = term.valueType === "raw" ? raw(entry, term.bucket) : pct(entry, term.bucket);- if (term.placement === "numerator") numerator *= value;- else denominator *= value;- }- return numerator / denominator;-}--function offenseFactor(entry: StaticDamageProfileEntry): number {- return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.attacker);-}--function defenseFactor(entry: StaticDamageProfileEntry): number {- return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.defender);-}--function raw(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {- return Math.max(0, entry.buckets[bucket]?.raw ?? 0);-}--function pct(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;-}--function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {- buckets[bucket] = { raw, contributors: [] };-}--function setPct(- buckets: StaticDamageProfileEntry["buckets"],- bucket: StaticPlayerBucket,- valuePct: number,- contributors: DamageBucketTrace["contributors"] = []-): void {- buckets[bucket] = { totalPct: valuePct, contributors };-}--function addPct(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticPassiveBucket, valuePct: number, contributor: DamageBucketTrace["contributors"][number]): void {- const existing = buckets[bucket];- if (existing) {- existing.totalPct = (existing.totalPct ?? 0) + valuePct;- existing.contributors.push(contributor);- } else {- buckets[bucket] = { totalPct: valuePct, contributors: [contributor] };- }-}--function fallbackStats(): StatBlock {- return { attack: 1, defense: 1, lethality: 1, health: 1 };-}--function emptyStats(): StatBlock {- return { attack: 0, defense: 0, lethality: 0, health: 0 };-}---const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);-const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);--// The typed manifest above is a convenience for literal types; the BUCKETS tree is the-// authority for which buckets are static. Fail loudly if the two ever drift.-{- const manifest = [...STATIC_BUCKET_SET].sort().join(",");- const tree = [...STATIC_BUCKETS].sort().join(",");- if (manifest !== tree) {- throw new Error(`static bucket manifest drifted from BUCKETS tree:\n manifest=[${manifest}]\n tree=[${tree}]`);- }-}+const FALLBACK_STATS: StatBlock = { attack: 1, defense: 1, lethality: 1, health: 1 };+const EMPTY_STATS: StatBlock = { attack: 0, defense: 0, lethality: 0, health: 0 };Index: simulator/src/testcases.test.ts===================================================================--- simulator/src/testcases.test.ts prev run+++ simulator/src/testcases.test.ts this run@@ -139,9 +139,9 @@assert.equal(report.counts.errors, 1);assert.equal(report.counts.executed, 1);assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");- assert.equal(report.details[0]?.errorDetails?.groupId, "player.defender.health");+ assert.equal(report.details[0]?.errorDetails?.groupId, "player.taker.health");assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");});Index: simulator/src/testcases.ts===================================================================--- simulator/src/testcases.ts prev run+++ simulator/src/testcases.ts this run@@ -8,10 +8,10 @@type SampleStats,sampleStats} from "./calibration";import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";-import { simulateBattle, prepareBattle, runPrepared } from "./simulator";-import { DamageAggregationError } from "./damage";+import { prepareBattle, runPrepared } from "./simulator";+import { DamageAggregationError } from "./staticDamageProfile";import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";const DEFAULT_STOCHASTIC_REPEAT = 100;const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;@@ -495,10 +495,12 @@}function simulateAdjustedDistribution(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): SampleStats {const samples: number[] = [];+ const compiled = prepareBattle(input, config);for (let iteration = 0; iteration < job.repeat; iteration += 1) {- const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { mode: "fast" });+ const seed = sampleSeed(job.seed ?? input.seed, job.file, job.testcaseId, job.index, iteration);+ const result = runPrepared(compiled, seed, { mode: "fast" });const score = battleScoreDelta(result);if (score !== undefined) samples.push(score);}return sampleStats(samples);@@ -772,10 +774,8 @@if (error instanceof DamageAggregationError) {return {type: error.name,groupId: error.groupId,- round: error.round,- jobId: error.jobId,netPct: error.netPct,factor: error.factor,contributors: error.contributors};@@ -801,12 +801,8 @@function sampleSeed(baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): string {return `${baseSeed ?? "simulator-default"}:${relative(process.cwd(), file)}:${testcaseId}:${index}:${iteration}`;}-function sampleInput(input: BattleInput, baseSeed: string | number | undefined, file: string, testcaseId: string, index: number, iteration: number): BattleInput {- return { ...input, seed: sampleSeed(baseSeed ?? input.seed, file, testcaseId, index, iteration) };-}-function snapshotKey(filePath: string, index: number): string {return `${normalizeReportPath(filePath)}#${index}`;}Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -95,8 +95,18 @@same_effect_stacking?: SameEffectStacking;reason?: string;}+export interface ResolvedEffectIntentDefinition extends EffectIntentDefinition {+ // Canonical config object retained across per-instance/level resolution. Duplicate+ // main/joiner copies use this identity to share prepared activation groups.+ sourceDefinition: Omit<EffectIntentDefinition, "id">;+ // Fixed-scope effects take the direct group fast path. Dynamically resolved scopes+ // use the compact scope-key table populated during battle preparation.+ damageGroup?: ActiveEffectGroup;+ damageGroupsByScopeKey?: Array<ActiveEffectGroup | undefined>;+}+export interface TriggerDefinition {type: string;probability?: unknown;first?: number;@@ -202,9 +212,9 @@heroRole?: "main" | "joiner";troopType?: UnitType;level: number;trigger: TriggerDefinition;- effects: EffectIntentDefinition[];+ effects: ResolvedEffectIntentDefinition[];compiledTrigger?: {definition: TriggerDefinition;side: SideId;level: number;@@ -246,8 +256,17 @@skillName?: string;effectId?: string;}+// Runtime damage modifiers with the same definition and resolved unit scopes share one+// stable group in the job-shape index. Each activation records its dense position so+// expiry can swap-remove it once, irrespective of how many job shapes reference the group.+export interface ActiveEffectGroup {+ effects: ActiveEffect[];+ bucketIndex: number;+ sameEffectStacking: SameEffectStacking;+}+export interface ActiveEffect {id: string;expired?: boolean;source: EffectSource;@@ -276,8 +295,10 @@// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.uses: number;stackingKey?: string;sameEffectStacking: SameEffectStacking;+ damageIndexGroup?: ActiveEffectGroup;+ damageIndexPosition?: number;}export interface EvolvingActiveEffect extends ActiveEffect {valueEvolution: {@@ -290,12 +311,12 @@export interface AttackIntent {id: string;round: number;source: "normal";- attackerSide: SideId;- attackerUnit: UnitType;- defenderSide: SideId;- defenderUnit: UnitType;+ dealerSide: SideId;+ dealerUnit: UnitType;+ takerSide: SideId;+ takerUnit: UnitType;orderIndex: number;previousAttackCount: number;projectedAttackCount: number;previousReceivedAttackCount: number;@@ -307,12 +328,12 @@round: number;kind: DamageKind;sourceIntentId: string;roundStartTroops: Record<SideId, Record<UnitType, number>>;- attackerSide: SideId;- attackerUnit: UnitType;- defenderSide: SideId;- defenderUnit: UnitType;+ dealerSide: SideId;+ dealerUnit: UnitType;+ takerSide: SideId;+ takerUnit: UnitType;sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;}@@ -353,9 +374,9 @@finalKills: number;}// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.-// Events are built only in trace mode; the uses counter is charged in every mode.+// Events are built by the standard and full-trace recorders; the uses counter is charged in every mode.interface AppliedEffectBase {// ActiveEffect.id — the runtime instance. Static-profile/input-stat contributors have no// ActiveEffect and use the config-level effectId here too.activeEffectId: string;@@ -395,12 +416,12 @@round: number;kind: DamageKind;sourceEffectId?: string;sourceSkillReportKey?: string;- attackerSide: SideId;- attackerUnit: UnitType;- defenderSide: SideId;- defenderUnit: UnitType;+ dealerSide: SideId;+ dealerUnit: UnitType;+ takerSide: SideId;+ takerUnit: UnitType;kills: number;counterDeltas: CounterDelta[];appliedEffects: AppliedEffect[];cancelledBy?: string;@@ -414,9 +435,11 @@troopType?: UnitType;skillId: string;skillName: string;level: number;+ /** Matching trigger opportunities, including failed probability rolls; populated only in trace mode. */triggersSeen: number;+ /** Successful triggers whose probability gate passed. */skillActivations: number;effectActivations: number;skillKills: number;unsupportedEffects: string[];
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/config/hero_definitions/Alonso.json b/simulator/config/hero_definitions/Alonso.json--- a/simulator/config/hero_definitions/Alonso.json+++ b/simulator/config/hero_definitions/Alonso.json@@ -13,8 +13,7 @@40,40,40- ],- "source": "self.any"+ ]},"effects": {"Onslaught/1": {@@ -26,10 +25,6 @@40,50],- "units": {- "applies_to": "trigger",- "applies_vs": "trigger.target"- },"duration": {"turns": {"count": 1diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json--- a/simulator/config/hero_definitions/Gwen.json+++ b/simulator/config/hero_definitions/Gwen.json@@ -113,9 +113,11 @@]},"duration": {- "attacks": {- "count": 1,+ "turns": {"delay": 1+ },+ "attacks": {+ "count": 1}},"trigger_damage_jobs": [diff --git a/simulator/config/hero_definitions/Mia.json b/simulator/config/hero_definitions/Mia.json--- a/simulator/config/hero_definitions/Mia.json+++ b/simulator/config/hero_definitions/Mia.json@@ -30,7 +30,8 @@},"duration": {"turns": {- "count": 1+ "count": 1,+ "delay": 1}},"same_effect_stacking": "max"diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts--- a/simulator/src/classifierDamage.test.ts+++ b/simulator/src/classifierDamage.test.ts@@ -4,8 +4,8 @@import { classifyEffectForJob } from "./classifier";import { calculateDamageJob } from "./damage";import { ATOMIC_BUCKETS } from "./damageBuckets";-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";-import { activateEffect } from "./effects";+import { createEffectIndex, indexEffect } from "./effectIndex";+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";import { ALL_UNIT_MASK, unitMask } from "./types";@@ -33,12 +33,15 @@intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ bucketIndex: -1,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask(["infantry"]) },appliesVs: { side: "defender", units: unitMask(["lancer"]) },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -78,12 +81,15 @@intent: { id: `${type}/1`, type, value: [valuePct] },ownerSide,kind: "modifier",- valuePct,+ bucketIndex: -1,+ initialValuePct: valuePct,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -100,10 +106,9 @@for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);- removeStaticProfileBucketEffects(effectIndex);- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const usedEffects = options.usedEffects ?? [];const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });- return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };+ return { ...result, usedEffectIds: usedEffects.map((usedEffect) => usedEffect.id) };}test("classifier routes up/down effects into neutral atomic buckets", () => {@@ -418,7 +423,7 @@const oneAttackEffect = {...effect("active.hero.attack.up", "attacker", 100),id: "attack-up-active",- duration: { type: "attack" as const, value: 1 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });@@ -432,7 +437,7 @@...effect("active.hero.defense.down", "defender", 30),id: "bad-luck-like",source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },- duration: { type: "round" as const, value: 1 }+ duration: { turns: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });@@ -456,7 +461,7 @@const defenderOutgoingBuff = {...effect("active.hero.lethality.up", "defender", 100),id: "defender-outgoing-buff",- duration: { type: "attack" as const, value: 1 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });@@ -475,7 +480,9 @@value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 15 }},- duration: { type: "attack" as const, value: 10 },+ duration: { attacks: { count: 10 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 2};@@ -499,7 +506,9 @@},createdRound: 0,startRound: 0,- duration: { type: "attack" as const, value: 10 }+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,+ duration: { attacks: { count: 10 } }};const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });@@ -513,7 +522,7 @@const weaker = {...effect("active.hero.lethality.up", "attacker", 50),id: "max-weaker",- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },stackingKey: "same-max-group",sameEffectStacking: "max" as const};@@ -526,7 +535,9 @@value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 50 }},- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 1,stackingKey: "same-max-group",sameEffectStacking: "max" as constdiff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts--- a/simulator/src/config.test.ts+++ b/simulator/src/config.test.ts@@ -9,6 +9,15 @@import { loadSimulatorConfigFromDir } from "./config-node";import type { SkillFile } from "./types";+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];++ assert.deepEqual(effect.duration, {+ turns: { delay: 1 },+ attacks: { count: 1 }+ });+});+test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {const root = writeConfigWithTroopEffect({type: "active.hero.lethality.up",diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -275,11 +275,11 @@const path = `${file}:${skillId}.${effectId}.duration`;const duration = effect.duration as Record<string, unknown>;for (const key of Object.keys(duration)) {- if (key !== "turns" && key !== "rounds" && key !== "attacks") {+ if (key !== "turns" && key !== "attacks") {throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);}}- for (const key of ["turns", "rounds", "attacks"] as const) {+ for (const key of ["turns", "attacks"] as const) {const value = duration[key];if (value === undefined) continue;validateNativeEffectDurationAxis(value, `${path}.${key}`);diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -5,15 +5,13 @@DamageEquationTrace,DamageJob,ResolvedFighter,- SameEffectStacking,SideId,UnitType} from "./types";import { UNIT_TYPES } from "./types";-import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";+import { advanceEffectAttackDelay, sourceLabel } from "./effects";+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,type StaticDamageBucket,@@ -33,15 +31,9 @@appliesTo?: DamageJob["kind"];}-interface BucketCandidate {- effect: ActiveEffect;- bucket: AtomicBucket;- valuePct: number;-}--interface MaxBucketCandidateGroup {- selected: BucketCandidate;- candidates: BucketCandidate[];+interface MaxBucketEffectGroup {+ selected: ActiveEffect;+ effects: ActiveEffect[];}interface DamageExpressionResult {@@ -67,13 +59,25 @@const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};-const TROOPS_COUNT_TERM = factorTerm("troops.count");-const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");+const TROOPS_COUNT_INDEX = BUCKET_IDS["troops.count"];+const SOURCE_EXTRA_SKILL_INDEX = BUCKET_IDS["source.extraSkill"];const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;+const factorSlots = (terms: DamageFactorTerm[], kind: DamageJob["kind"]): Int32Array =>+ Int32Array.from(terms.filter((term) => !term.appliesTo || term.appliesTo === kind).map((term) => term.bucket));+const DEFAULT_NUMERATOR_SLOTS = { normal: factorSlots(DEFAULT_NUMERATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_NUMERATOR_TERMS, "skill") };+const DEFAULT_DENOMINATOR_SLOTS = { normal: factorSlots(DEFAULT_DENOMINATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_DENOMINATOR_TERMS, "skill") };+const PROFILED_NUMERATOR_SLOTS = { normal: factorSlots(PROFILED_NUMERATOR_TERMS, "normal"), skill: factorSlots(PROFILED_NUMERATOR_TERMS, "skill") };+const PROFILED_DENOMINATOR_SLOTS = DEFAULT_DENOMINATOR_SLOTS;+const BUCKET_UPDATE_BY_INDEX = Uint8Array.from(+ ATOMIC_BUCKETS.map((bucket) => {+ const update = BUCKET_DEFINITIONS[bucket].update;+ return update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2;+ })+);export class DamageAggregationError extends Error {readonly groupId: string;@@ -106,7 +110,7 @@job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }+ options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");// The damage math is one path; `trace` only decides whether we also capture the (expensive)@@ -123,47 +127,27 @@const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));const needsTraceBuckets = traceEnabled;const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);- applyBucketValue(buckets, "troops.count", armyTerm);- applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);+ buckets.factors[TROOPS_COUNT_INDEX] = Math.max(0, armyTerm);+ buckets.factors[SOURCE_EXTRA_SKILL_INDEX] = Math.max(0, job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);const appliedEffects: DamageEquationTrace["appliedEffects"] = [];const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();- const candidates: BucketCandidate[] = [];- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- if (!isEffectActive(candidate.effect, job.round)) continue;- handledCandidateEffectIds?.add(candidate.effect.id);- candidates.push({- effect: candidate.effect,- bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round)- });- }-- if (traceEnabled) {- for (const effect of options.effectIndex.all) {- if (handledCandidateEffectIds?.has(effect.id)) continue;- if (!isEffectActive(effect, job.round)) {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });- continue;- }- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "bucket" && classification.bucket) {- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);- }- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });- }- }+ const usedEffects = options.usedEffects ?? [];+ applyBucketEffects(+ damageEffectsForJob(options.effectIndex, job),+ job.round,+ buckets,+ detail,+ recordAppliedEffects,+ appliedEffects,+ rejectedEffects,+ usedEffects+ );- applyBucketCandidates(candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);- const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];- const traceBuckets = needsTraceBuckets ? toTraceBuckets(buckets, staticTraceEntries) : undefined;- const expressionDetail = traceEnabled ? "full" : "fast";- const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, expressionDetail, staticProfile);+ const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, detail, staticProfile);const uncappedKills = Math.max(0, rawDamage);const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);const trace = traceEnabled@@ -173,7 +157,10 @@defender: { ...job.roundStartTroops.defender }},armyTerm,- atomicBuckets: traceBuckets ?? toTraceBuckets(buckets, staticTraceEntries),+ atomicBuckets: toTraceBuckets(buckets, [+ staticProfile.offense[job.attackerSide][job.attackerUnit],+ staticProfile.defense[job.defenderSide][job.defenderUnit]+ ]),aggregationGroups,appliedEffects,rejectedEffects,@@ -189,69 +176,79 @@};}-function applyBucketCandidates(- candidates: BucketCandidate[],+function applyBucketEffects(+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;- for (const candidate of candidates) {- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;+ for (const effect of effects) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {maxGroups ??= new Map();- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;+ const key = `${effect.intent.type}:${effect.stackingKey}`;const group = maxGroups.get(key);if (group) {- group.candidates.push(candidate);- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;+ group.effects.push(effect);+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;} else {- maxGroups.set(key, { selected: candidate, candidates: [candidate] });+ maxGroups.set(key, { selected: effect, effects: [effect] });}} else {- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the// applied percentage. Shared by the single-candidate and max-group paths.function applySelectedBucket(- selected: BucketCandidate,+ selected: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"]): number {- const appliedValuePct = applyBucketValue(- buckets,- selected.bucket,- selected.valuePct,- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",- detail === "full" ? sourceLabel(selected.effect) : "",- detail === "full" ? selected.effect.ownerSide : undefined,- selected.bucket,- selected.effect.stackingKey,- selected.effect.sameEffectStacking- );+ const appliedValuePct = selected.getCurrentValuePct(round);+ const bucketIndex = selected.bucketIndex;+ const update = BUCKET_UPDATE_BY_INDEX[bucketIndex];+ if (update === 0) buckets.factors[bucketIndex] = Math.max(0, appliedValuePct);+ else if (update === 1) buckets.factors[bucketIndex] *= 1 + appliedValuePct / 100;+ else buckets.factors[bucketIndex] += appliedValuePct / 100;+ if (detail === "full") {+ buckets.contributors?.[bucketIndex].push({+ effectId: selected.source.effectId ?? selected.id,+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ valuePct: appliedValuePct,+ bucket: selected.intent.type,+ stackingKey: selected.stackingKey,+ sameEffectStacking: selected.sameEffectStacking+ });+ }if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",- activeEffectId: selected.effect.id,- effectId: selected.effect.source.effectId ?? selected.effect.id,- bucket: selected.bucket,+ activeEffectId: selected.id,+ effectId: selected.source.effectId ?? selected.id,+ bucket: selected.intent.type,valuePct: appliedValuePct,- source: sourceLabel(selected.effect),- sourceSide: selected.effect.ownerSide,- sameEffectStacking: selected.effect.sameEffectStacking+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ sameEffectStacking: selected.sameEffectStacking};- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;appliedEffects.push(appliedEffect);}return appliedValuePct;@@ -259,45 +256,47 @@// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]// array and no suppressed-sibling bookkeeping.-function applyBucketCandidate(- candidate: BucketCandidate,+function applyBucketEffect(+ effect: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {- usedEffects.add(candidate.effect);+ usedEffects.push(effect);} else if (detail === "full") {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}-function applyBucketCandidateGroup(- selected: BucketCandidate,- candidates: BucketCandidate[],+function applyBucketEffectGroup(+ selected: ActiveEffect,+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.- for (const candidate of candidates) {- usedEffects.add(candidate.effect);- if (detail === "full" && candidate !== selected) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });+ for (const effect of effects) {+ usedEffects.push(effect);+ if (detail === "full" && effect !== selected) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });}}} else if (detail === "full") {- for (const candidate of candidates) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ for (const effect of effects) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}}@@ -331,26 +330,6 @@return buckets;}-function applyBucketValue(- buckets: NumericDamageBuckets,- bucketName: AtomicBucket,- value: number,- effectId = "",- source = "",- sourceSide: SideId | undefined = undefined,- traceBucketName = bucketName,- stackingKey?: string,- sameEffectStacking: SameEffectStacking = "add"-): number {- const index = BUCKET_IDS[bucketName];- const definition = BUCKET_DEFINITIONS[bucketName];- if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);- else if (definition.update === "multiply_pct_factor") buckets.factors[index] *= 1 + value / 100;- else buckets.factors[index] += value / 100;- if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });- return value;-}-function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {if (!bucket.startsWith("passive.") || !term) continue;@@ -372,10 +351,11 @@function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);+ const factors = buckets.factors;let numerator = 1;- for (const term of DEFAULT_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];let denominator = 1;- for (const term of DEFAULT_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];return {rawDamage: numerator / denominator,aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS@@ -388,15 +368,18 @@detail: DamageDetail,staticProfile: StaticDamageProfile): DamageExpressionResult {- validateProfiledStaticFactors(job, staticProfile);+ const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];+ const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];+ if (!offense.playerFactorsValid || !defense.playerFactorsValid) validateProfiledStaticFactors(job, staticProfile);+ const factors = buckets.factors;let numerator =- valueForTerm(TROOPS_COUNT_TERM, job, buckets) *- valueForTerm(SOURCE_EXTRA_SKILL_TERM, job, buckets) *- staticProfile.offense[job.attackerSide][job.attackerUnit].factor *- staticProfile.defense[job.defenderSide][job.defenderUnit].factor;- for (const term of PROFILED_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ factors[TROOPS_COUNT_INDEX] *+ factors[SOURCE_EXTRA_SKILL_INDEX] *+ offense.factor *+ defense.factor;+ for (const slot of PROFILED_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];let denominator = 100;- for (const term of PROFILED_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];return {rawDamage: numerator / denominator,aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS@@ -425,11 +408,6 @@});}-function valueForTerm(term: DamageFactorTerm, job: DamageJob, buckets: NumericDamageBuckets): number {- if (term.appliesTo && term.appliesTo !== job.kind) return 1;- return buckets.factors[term.bucket];-}-function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts--- a/simulator/src/effectIndex.test.ts+++ b/simulator/src/effectIndex.test.ts@@ -1,7 +1,7 @@import assert from "node:assert/strict";import { test } from "node:test";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";+import { cloneEffectIndex, createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";import { unitMask } from "./types";import type { ActiveEffect, DamageJob } from "./types";@@ -13,12 +13,15 @@intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ bucketIndex: -1,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"@@ -40,20 +43,36 @@defenderUnit: "lancer"};- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);});-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {+test("effect index excludes static-profile bucket effects", () => {const index = createEffectIndex();const passive = effect("passive.attack.up");const active = effect("active.hero.lethality.up");indexEffect(index, passive);indexEffect(index, active);- removeStaticProfileBucketEffects(index);+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);+});++test("cloned effect index remaps shared effects while owning its bucket arrays", () => {+ const index = createEffectIndex();+ const original = effect("active.hero.lethality.up");+ const replacement = { ...original };+ indexEffect(index, original);++ const clone = cloneEffectIndex(index, (candidate) => {+ assert.equal(candidate, original);+ return replacement;+ });+ const originalCandidates = damageEffectsForJob(index, job());+ const clonedCandidates = damageEffectsForJob(clone, job());- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);- assert.deepEqual(index.all, [active]);+ assert.notEqual(clonedCandidates, originalCandidates);+ assert.deepEqual(clonedCandidates, [replacement]);+ clonedCandidates.push(effect("active.hero.attack.up"));+ assert.deepEqual(originalCandidates, [original]);});function effect(type: string): ActiveEffect {@@ -63,12 +82,15 @@intent: { id: type, type, value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ bucketIndex: -1,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts--- a/simulator/src/effectIndex.ts+++ b/simulator/src/effectIndex.ts@@ -1,26 +1,19 @@import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";import { unitsFromMask } from "./types";-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";-import { isStaticProfileBucket } from "./staticDamageProfile";--export interface IndexedBucketEffect {- effect: ActiveEffect;- bucket: AtomicBucket;-}+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";export interface EffectIndex {- all: ActiveEffect[];- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;+ damageByJobShape: Array<ActiveEffect[] | undefined>;controls: ActiveEffect[];extraAttacks: ActiveEffect[];battleOrder: ActiveEffect[];}-const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));export function createEffectIndex(): EffectIndex {return {- all: [],damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),controls: [],extraAttacks: [],@@ -28,8 +21,16 @@};}+export function cloneEffectIndex(index: EffectIndex, cloneEffect: (effect: ActiveEffect) => ActiveEffect): EffectIndex {+ return {+ damageByJobShape: index.damageByJobShape.map((effects) => effects?.map(cloneEffect)),+ controls: index.controls.map(cloneEffect),+ extraAttacks: index.extraAttacks.map(cloneEffect),+ battleOrder: index.battleOrder.map(cloneEffect)+ };+}+export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {- index.all.push(effect);if (effect.kind === "control") {index.controls.push(effect);return;@@ -47,58 +48,33 @@// Static-phase buckets (passive.*) are aggregated by the static damage profile, not the// per-job runtime path, so they must not enter the damage job-shape index.if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;+ effect.bucketIndex = BUCKET_INDEX.get(definition.path) ?? -1;- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];- const appliesToUnits = unitsFromMask(effect.appliesTo.units);- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);- for (const jobKind of jobKinds) {- for (const appliesToUnit of appliesToUnits) {- for (const appliesVsUnit of appliesVsUnits) {- const slot =- definition.role === "attacker"- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);- const arr = index.damageByJobShape[slot];- const candidate = { effect, bucket: definition.path };- if (arr) arr.push(candidate);- else index.damageByJobShape[slot] = [candidate];- }- }+ for (const slot of shapeSlotsFor(effect, definition.path)) {+ const arr = index.damageByJobShape[slot];+ if (arr) arr.push(effect);+ else index.damageByJobShape[slot] = [effect];}}-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {- compactEffects(index.all, isActive);- compactEffects(index.controls, isActive);- compactEffects(index.extraAttacks, isActive);- compactEffects(index.battleOrder, isActive);- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- compactCandidates(arr, isActive);- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;+ const definition = bucketDefinition(effect.intent.type);+ return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";}-export function removeStaticProfileBucketEffects(index: EffectIndex): void {- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- let write = 0;- for (let read = 0; read < arr.length; read += 1) {- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }- }- arr.length = write;- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {+ effect.expired = true;+ if (effect.kind === "control") removeStable(index.controls, effect);+ else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);+ else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);}-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];}-function damageJobShapeSlot(+export function damageJobShapeSlot(jobKind: DamageKind,attackerSide: SideId,attackerUnit: UnitType,@@ -108,6 +84,36 @@return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);}+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();+function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const key =+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 ++ (effect.appliesVs.units & 7);+ const cached = SHAPE_SLOTS_CACHE.get(key);+ if (cached) return cached;+ const slots = buildShapeSlots(effect, bucket);+ SHAPE_SLOTS_CACHE.set(key, slots);+ return slots;+}++function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const definition = bucketDefinition(bucket)!;+ const slots: number[] = [];+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];+ for (const jobKind of jobKinds) {+ for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {+ for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {+ slots.push(+ definition.role === "attacker"+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)+ );+ }+ }+ }+ return Uint8Array.from(slots);+}+function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {@@ -116,29 +122,7 @@return 2;}-function isStaticProfileEffect(effect: ActiveEffect): boolean {- const definition = bucketDefinition(effect.intent.type);- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));-}--function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < effects.length; read += 1) {- const effect = effects[read];- if (!isActive(effect)) continue;- effects[write] = effect;- write += 1;- }- effects.length = write;-}--function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < candidates.length; read += 1) {- const candidate = candidates[read];- if (!isActive(candidate.effect)) continue;- candidates[write] = candidate;- write += 1;- }- candidates.length = write;+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {+ const index = effects.indexOf(effect);+ if (index >= 0) effects.splice(index, 1);}diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -4,24 +4,38 @@AttackIntent,EffectDuration,EffectIntentDefinition,+ EvolvingActiveEffect,ResolvedSkill,ResolvedUnitScope,SameEffectStacking,SideId,UnitType} from "./types";-import { ALL_UNIT_MASK, unitMask } from "./types";+import { ALL_UNIT_MASK, unitMask, unitMaskHas } from "./types";import { normalizeUnitType } from "./normalize";export type Rng = () => number;-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];--interface CompiledTriggerSelectors {- source: ParsedTriggerSelector;- target: ParsedTriggerSelector;+interface CompiledActivation {+ idPrefix: string;+ source: ActiveEffect["source"];+ ownerSide: SideId;+ kind: ActiveEffectKind;+ initialValuePct: number;+ getCurrentValuePct: ActiveEffect["getCurrentValuePct"];+ valueEvolution?: EvolvingActiveEffect["valueEvolution"];+ triggerDamageJobs: ActiveEffect["triggerDamageJobs"];+ duration: EffectDuration;+ turnDelay: number;+ attackDelay: number;+ stackingKey: string;+ sameEffectStacking: SameEffectStacking;+ staticAppliesTo: ResolvedUnitScope;+ staticAppliesVs: ResolvedUnitScope;+ intentScoped: boolean;}-const TRIGGER_SELECTOR_CACHE = new WeakMap<ResolvedSkill, CompiledTriggerSelectors>();+const ACTIVATION_CACHE = new WeakMap<EffectIntentDefinition, CompiledActivation>();+const INTENT_SCOPED_VALUES = new Set(["trigger.source", "trigger", "trigger.target", "target"]);export function oppositeSide(side: SideId): SideId {return side === "attacker" ? "defender" : "attacker";@@ -40,32 +54,47 @@if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every, trigger.first)) return false;if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every, trigger.first)) return false;if (!intent) return true;- const selectors = compiledTriggerSelectors(skill);+ const selectors = compiledTriggerForSkill(skill);return (- triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&- triggerSelectorMatches(selectors.target, skill.side, intent.defenderSide, intent.defenderUnit)+ triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&+ triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit));}-function compiledTriggerSelectors(skill: ResolvedSkill): CompiledTriggerSelectors {- const cached = TRIGGER_SELECTOR_CACHE.get(skill);- if (cached) return cached;+export function compiledTriggerForSkill(skill: ResolvedSkill): NonNullable<ResolvedSkill["compiledTrigger"]> {+ const cached = skill.compiledTrigger;+ if (cached && cached.definition === skill.trigger && cached.side === skill.side && cached.level === skill.level) return cached;const compiled = {- source: parseTriggerSelector(skill.trigger.source, "self"),- target: parseTriggerSelector(skill.trigger.target, "enemy")+ definition: skill.trigger,+ side: skill.side,+ level: skill.level,+ source: compileTriggerScope(skill, skill.trigger.source, "self"),+ target: compileTriggerScope(skill, skill.trigger.target, "enemy"),+ probabilityPct: resolvedProbabilityPct(skill)};- TRIGGER_SELECTOR_CACHE.set(skill, compiled);+ skill.compiledTrigger = compiled;return compiled;}-export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+function compileTriggerScope(skill: ResolvedSkill, value: unknown, defaultRelation: TriggerSelectorRelation): ResolvedUnitScope {+ const selector = parseTriggerSelector(value, defaultRelation);+ return {+ side: sideForTriggerRelation(skill.side, selector.relation),+ units: selector.units ? unitMask(selector.units) : ALL_UNIT_MASK+ };+}++function resolvedProbabilityPct(skill: ResolvedSkill): number {const probability = skill.trigger.probability;- if (probability === undefined) return true;- const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);- if (!Number.isFinite(value) || value <= 0) return false;+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability ?? 100);+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;+}++export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+ const value = compiledTriggerForSkill(skill).probabilityPct;+ if (value <= 0) return false;if (value >= 100) return true;- const threshold = value / 100;- return rng() < threshold;+ return rng() < value / 100;}export function createSeededRng(seed: string | number = "simulator-default"): Rng {@@ -82,20 +111,22 @@return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);}-export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+function compiledActivation(skill: ResolvedSkill, intent: EffectIntentDefinition): CompiledActivation {+ const cached = ACTIVATION_CACHE.get(intent);+ if (cached) return cached;const units = intent.units ?? {};const ownerSide = skill.side;- const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);- const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);+ const staticAppliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", undefined, ownerSide);+ const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);const duration = normalizeDuration(intent.duration);- const delay = duration.delay ?? 0;const effectKind = kindForIntent(intent);if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);}const sourceKey = skillActivationSourceKey(skill);- return {- id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,+ const evolution = intent.value_evolution;+ const compiled: CompiledActivation = {+ idPrefix: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r`,source: {kind: skill.sourceKind,side: skill.side,@@ -106,20 +137,65 @@skillName: skill.name,effectId: intent.id},- intent,ownerSide,kind: effectKind,- valuePct: typeof intent.value === "number" ? intent.value : undefined,+ initialValuePct: finiteNumberOrZero(intent.value),+ getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,+ valueEvolution: evolution+ ? {+ type: evolution.type,+ step: evolution.step,+ amount: finiteNumberOrZero(evolution.value)+ }+ : undefined,+ triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ duration,+ turnDelay: Math.max(0, duration.turns?.delay ?? 0),+ attackDelay: duration.attacks?.delay ?? 0,+ stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,+ sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking),+ staticAppliesTo,+ staticAppliesVs,+ intentScoped:+ (typeof units.applies_to === "string" && INTENT_SCOPED_VALUES.has(units.applies_to)) ||+ (typeof units.applies_vs === "string" && INTENT_SCOPED_VALUES.has(units.applies_vs))+ };+ ACTIVATION_CACHE.set(intent, compiled);+ return compiled;+}++export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+ const compiled = compiledActivation(skill, intent);+ let appliesTo = compiled.staticAppliesTo;+ let appliesVs = compiled.staticAppliesVs;+ if (compiled.intentScoped && attackIntent) {+ const units = intent.units ?? {};+ appliesTo = resolveUnitScope(units.applies_to, compiled.ownerSide, "applies_to", attackIntent, compiled.ownerSide);+ appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, compiled.ownerSide);+ }+ const effect: ActiveEffect = {+ id: compiled.idPrefix + round + ":" + (attackIntent?.id ?? "global"),+ expired: false,+ source: compiled.source,+ intent,+ ownerSide: compiled.ownerSide,+ kind: compiled.kind,+ bucketIndex: -1,+ initialValuePct: compiled.initialValuePct,+ getCurrentValuePct: compiled.getCurrentValuePct,appliesTo,appliesVs,- triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ triggerDamageJobs: compiled.triggerDamageJobs,createdRound: round,- startRound: round + delay,- duration,+ startRound: Math.max(1, round + compiled.turnDelay),+ duration: compiled.duration,+ remainingAttackDelay: compiled.attackDelay,uses: 0,- stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,- sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)+ stackingKey: compiled.stackingKey,+ sameEffectStacking: compiled.sameEffectStacking};+ if (!compiled.valueEvolution) return effect;+ return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;}function skillActivationSourceKey(skill: ResolvedSkill): string {@@ -134,30 +210,56 @@return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");}-export function isEffectActive(effect: ActiveEffect, round: number): boolean {- if (round < effect.startRound) return false;- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {+ return effect.duration.attacks !== undefined;}-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");-}--export function currentEffectValuePct(effect: ActiveEffect, round: number): number {- const baseValue = Number(effect.valuePct ?? 0);- if (!Number.isFinite(baseValue)) return 0;- const evolution = effect.intent.value_evolution;- if (!evolution) return baseValue;- const firstActiveRound = Math.max(1, effect.startRound);- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;- const amount = Number(evolution.value ?? 0);- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {+ const count = effect.duration.attacks?.count;+ return count === undefined ? undefined : Math.max(1, count);+}++export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {+ const turns = effect.duration.turns;+ if (!turns) return undefined;+ return {+ activationRound: effect.startRound,+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })+ };+}++export function isEffectAttackReady(effect: ActiveEffect): boolean {+ return effect.remainingAttackDelay <= 0;+}++// Returns true when the effect may apply to this eligible attack. The attack+// that consumes the final delay does not also consume the first active use.+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {+ const remaining = effect.remainingAttackDelay;+ if (remaining <= 0) return true;+ effect.remainingAttackDelay = remaining - 1;+ return false;+}++export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {+ return this.initialValuePct;+}++export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {+ const firstActiveRound = Math.max(1, this.startRound);+ const evolution = this.valueEvolution;+ const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;+ if (stepCount <= 0) return this.initialValuePct;if (evolution.type === "pct_decay") {- const factor = Math.max(0, 1 - amount / 100);- return baseValue * factor ** stepCount;+ const factor = Math.max(0, 1 - evolution.amount / 100);+ return this.initialValuePct * factor ** stepCount;}- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);- return baseValue;+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);+ return this.initialValuePct;+}++function finiteNumberOrZero(value: unknown): number {+ return typeof value === "number" && Number.isFinite(value) ? value : 0;}export type TriggerSelectorRelation = "self" | "enemy";@@ -183,9 +285,8 @@return relation === "self" ? skillSide : oppositeSide(skillSide);}-function triggerSelectorMatches(selector: ParsedTriggerSelector, skillSide: SideId, actualSide: SideId, actualUnit: UnitType): boolean {- if (sideForTriggerRelation(skillSide, selector.relation) !== actualSide) return false;- return selector.units === undefined || selector.units.includes(actualUnit);+function triggerScopeMatches(scope: ResolvedUnitScope, actualSide: SideId, actualUnit: UnitType): boolean {+ return scope.side === actualSide && unitMaskHas(scope.units, actualUnit);}export function normalizeEngagementType(value: unknown): string | undefined {@@ -237,40 +338,15 @@}function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {- if (!duration) return { type: "battle", value: 0 };- const namedConstraints = normalizeNamedDurationConstraints(duration);- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };- return { type: "battle", value: 0 };-}--function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {- return duration.constraints ?? [{ type: duration.type, count: duration.value }];-}--function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {- const delay = Math.max(0, constraint.delay ?? 0);- if (constraint.type === "battle") return true;- if (constraint.type === "round") {- const startRound = effect.createdRound + delay;- if (round < startRound) return false;- if (constraint.count === undefined) return true;- return round < startRound + Math.max(1, constraint.count);- }- if (constraint.count === undefined) return true;- return effect.uses < delay + Math.max(1, constraint.count);-}--function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {- const constraints: EffectDurationConstraint[] = [];- const turns = duration.turns ?? duration.rounds;- if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));- if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));- return constraints;+ if (!duration) return {};+ return {+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})+ };}-function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {return {- type,...(value.count === undefined ? {} : { count: Number(value.count) }),...(value.delay === undefined ? {} : { delay: Number(value.delay) })};diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -566,6 +566,139 @@assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});+test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedAfterPause: {+ name: "DelayedAfterPause",+ troop_type: "infantry",+ skills: {+ PauseFirstRound: {+ trigger: { type: "battle_start" },+ effects: {+ pause: {+ type: "no_attack",+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ DelayedAttackBudget: {+ trigger: { type: "turn", every: 99, first: 1 },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));+ const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ assert.equal(roundOne?.cancelReason, "no_attack");+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});++test("attack delay skips eligible attacks before the effect can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { marksman_t1: 1000000 },+ heroes: { DelayedExtra: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedExtra: {+ name: "DelayedExtra",+ troop_type: "marksman",+ skills: {+ NextAttack: {+ trigger: { type: "battle_start" },+ effects: {+ delayedExtra: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "self.marksman", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]+ }+ }+ }+ }+ }+ })+ );++ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");+ assert.equal(skillAttacks.length, 1);+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));+});++test("attack delay skips eligible damage jobs before a modifier can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedModifier: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedModifier: {+ name: "DelayedModifier",+ troop_type: "infantry",+ skills: {+ NextAttackBoost: {+ trigger: { type: "battle_start" },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter(+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"+ );+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle(diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -28,20 +28,32 @@import { createRecorder } from "./recorder";import {activateEffect,+ advanceEffectAttackDelay,chancePasses,+ compiledTriggerForSkill,+ constantActiveEffectValuePct,createSeededRng,- currentEffectValuePct,+ effectAttackUseLimit,+ effectRoundWindow,hasAttackDurationConstraint,- isEffectActive,+ isEffectAttackReady,oppositeSide,- parseTriggerSelector,- sideForTriggerRelation,skillMatchesTrigger,sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import {+ cloneEffectIndex,+ createEffectIndex,+ damageEffectsForJob,+ damageJobShapeSlot,+ DAMAGE_JOB_SHAPE_SLOTS,+ expireEffectIndex,+ indexEffect,+ isRuntimeIndexableEffect,+ type EffectIndex+} from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -53,11 +65,13 @@const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();interface Runtime {- activeEffects: ActiveEffect[];effectIndex: EffectIndex;+ preparedEffects: ActiveEffect[];+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;// Per-job scratch: effects that affected the job being calculated; drained by// chargeUsedEffects (uses += 1 each) after every job in every mode.- usedEffects: Set<ActiveEffect>;+ usedEffects: ActiveEffect[];staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;@@ -79,6 +93,7 @@roundStartGlobal: ResolvedSkill[];roundStartPerUnit: ResolvedSkill[];attackDeclared: ResolvedSkill[];+ attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;}interface ExtraAttackEffectGroup {@@ -215,33 +230,47 @@}// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage-// profile, and drop static-profile effects from the per-job index.-function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");- addInputPassiveEffects(runtime, input.defender.passive, "defender");- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);- removeStaticProfileBucketEffects(runtime.effectIndex);+// profile. Static-phase effects never enter the per-job index.+function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number, collectSkillReports = true): Runtime {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), collectSkillReports);+ const setupEffects = [+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")+ ];+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)-// and the index rebuilt from the clones; skills and the static profile are shared by reference.-function cloneRuntime(template: Runtime, rng: Rng): Runtime {- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));- const effectIndex = createEffectIndex();- for (const effect of activeEffects) indexEffect(effectIndex, effect);- removeStaticProfileBucketEffects(effectIndex);- return {- activeEffects,- effectIndex,- usedEffects: new Set(),+// Clone a prepared template's mutable per-run effect graph. The template already owns the+// correct index topology and round schedules, so remap those references to the effect clones+// instead of classifying and scheduling every effect again. Skills and the immutable static+// profile are shared by reference.+function cloneRuntime(template: Runtime, rng: Rng, collectSkillReports = true): Runtime {+ const effectClones = new Map<ActiveEffect, ActiveEffect>();+ const preparedEffects = template.preparedEffects.map((effect) => {+ const clone = { ...effect };+ effectClones.set(effect, clone);+ return clone;+ });+ const cloneEffect = (effect: ActiveEffect): ActiveEffect => {+ const clone = effectClones.get(effect);+ if (!clone) throw new Error(`prepared effect ${effect.id} is missing from the runtime template`);+ return clone;+ };++ const runtime: Runtime = {+ effectIndex: cloneEffectIndex(template.effectIndex, cloneEffect),+ preparedEffects,+ activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),+ expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),+ usedEffects: [],staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,skills: template.skills,- skillReports: cloneSkillReports(template.skillReports),+ skillReports: collectSkillReports ? cloneSkillReports(template.skillReports) : { attacker: new Map(), defender: new Map() },effectActivationCounts: { ...template.effectActivationCounts },extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },attackControlCounts: { ...template.attackControlCounts },@@ -250,6 +279,14 @@received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }}};+ return runtime;+}++function cloneEffectSchedule(+ schedule: Array<ActiveEffect[] | undefined>,+ cloneEffect: (effect: ActiveEffect) => ActiveEffect+): Array<ActiveEffect[] | undefined> {+ return schedule.map((effects) => effects?.map(cloneEffect));}function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {@@ -288,18 +325,19 @@prepared?: CompiledBattle,loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }): BattleRun {+ const collectSkillReports = (options.mode ?? "standard") !== "fast";if (prepared?.template) {const fighters: Record<SideId, ResolvedFighter> = {attacker: cloneFighterForRun(prepared.fighters.attacker),defender: cloneFighterForRun(prepared.fighters.defender)};- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"), collectSkillReports);return runLoop(input, fighters, runtime, options, loopOptions);}const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default");+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", collectSkillReports);return runLoop(input, fighters, runtime, options, loopOptions);}@@ -317,6 +355,15 @@dodge: options.useEffectsOnDodge ?? true,no_attack: options.useEffectsOnNoAttack ?? true};+ const damageJobOptions = {+ trace: recorder.capturesTrace,+ recordAppliedEffects: recorder.capturesAppliedEffects,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects+ };let rounds = 0;let score = 0;@@ -324,7 +371,7 @@if (winnerFor(fighters)) break;rounds = round;const roundStartTroops = snapshotTroops(fighters);- expireInactive(runtime, round);+ processEffectSchedule(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);// Applied-effect events keyed by the intent they ordered.@@ -340,13 +387,16 @@const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {- triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);+ const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[+ damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)+ ] ?? [];+ triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, intent);const job = normalJob(intent, roundStartTroops);declaredNormalJobs.push({ intent, job });}for (const { intent, job } of declaredNormalJobs) {- const controls = applicableControls(job, round, runtime);+ const controls = applicableControls(job, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;pendingNormalJobs.push({ intent, job, control });@@ -364,7 +414,9 @@if (control) {runtime.attackControlCounts[control.reason] += 1;- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ if (useEffectsOnCancel[control.reason]) {+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);+ }cancelled.push({intent,effectId: control.effect.id,@@ -376,16 +428,8 @@continue;}- allJobs.push(job);- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,- usedEffects: runtime.usedEffects- });+ if (recorder.capturesTrace) allJobs.push(job);+ const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;@@ -404,16 +448,8 @@for (const extraJob of extraSkill.jobs) {if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;- allJobs.push(extraJob);- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,- usedEffects: runtime.usedEffects- });+ if (recorder.capturesTrace) allJobs.push(extraJob);+ const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");processedExtraJobIds.add(extraJob.id);@@ -429,7 +465,7 @@normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));for (const usedEffectGroup of extraSkill.usedEffectGroups) {if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);}}@@ -501,7 +537,8 @@const side = roundTriggerSourceSide(skill);const defenderSide = roundTriggerTargetSide(skill, side);if (!skillMatchesTrigger(skill, "round_start", round)) continue;- const report = runtime.skillReports[skill.side].get(reportKey(skill));+ const reports = runtime.skillReports[skill.side];+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));if (report) report.triggersSeen += 1;if (!chancePasses(skill, runtime.rng)) continue;if (report) report.skillActivations += 1;@@ -529,20 +566,16 @@}function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {- const selector = parseTriggerSelector(skill.trigger.source, "self");- const side = sideForTriggerRelation(skill.side, selector.relation);- const living = UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0);- return selector.units === undefined ? living : living.filter((unit) => selector.units?.includes(unit));+ const source = compiledTriggerForSkill(skill).source;+ return UNIT_TYPES.filter((unit) => (roundStartTroops[source.side][unit] ?? 0) > 0 && unitMaskHas(source.units, unit));}function roundTriggerSourceSide(skill: ResolvedSkill): SideId {- const selector = parseTriggerSelector(skill.trigger.source, "self");- return sideForTriggerRelation(skill.side, selector.relation);+ return compiledTriggerForSkill(skill).source.side;}function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {- const selector = parseTriggerSelector(skill.trigger.target, "enemy");- const targetSide = sideForTriggerRelation(skill.side, selector.relation);+ const targetSide = compiledTriggerForSkill(skill).target.side;return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;}@@ -589,10 +622,10 @@return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {+function createRuntime(fighters: ResolvedFighter[], rng: Rng, collectSkillReports = true): Runtime {const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };const skills = buildRuntimeSkills(fighters);- for (const fighter of fighters) {+ for (const fighter of collectSkillReports ? fighters : []) {for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {reports[fighter.side].set(reportKey(skill), {sourceKind: skill.sourceKind,@@ -610,9 +643,11 @@}}return {- activeEffects: [],effectIndex: createEffectIndex(),- usedEffects: new Set(),+ preparedEffects: [],+ activateEffectsByRound: [],+ expireEffectsByRound: [],+ usedEffects: [],staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,@@ -630,45 +665,81 @@function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);+ for (const skill of all) compiledTriggerForSkill(skill);const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");const roundStart = all.filter((skill) => skill.trigger.type === "turn");const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");+ const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });+ for (const skill of attackDeclared) {+ const trigger = compiledTriggerForSkill(skill);+ for (const attackerUnit of unitsFromMask(trigger.source.units)) {+ for (const defenderUnit of unitsFromMask(trigger.target.units)) {+ const slot = damageJobShapeSlot("normal", trigger.source.side, attackerUnit, trigger.target.side, defenderUnit);+ const skills = attackDeclaredByJobShape[slot];+ if (skills) skills.push(skill);+ else attackDeclaredByJobShape[slot] = [skill];+ }+ }+ }return {all,battleStart,roundStart,roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),- attackDeclared+ attackDeclared,+ attackDeclaredByJobShape};}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {- runtime.activeEffects.push(effect);- indexEffect(runtime.effectIndex, effect);+ if (!isRuntimeIndexableEffect(effect)) return;+ const window = effectRoundWindow(effect);+ if (!window) {+ indexEffect(runtime.effectIndex, effect);+ return;+ }+ if (window.expirationRound !== undefined) {+ if (window.expirationRound <= window.activationRound) {+ effect.expired = true;+ return;+ }+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);+ }+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);+}++function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {+ const effects = schedule[round];+ if (effects) effects.push(effect);+ else schedule[round] = [effect];}-function expireInactive(runtime: Runtime, round: number): void {- let write = 0;- for (let read = 0; read < runtime.activeEffects.length; read += 1) {- const effect = runtime.activeEffects[read];- if (isEffectActive(effect, round)) {- runtime.activeEffects[write] = effect;- write += 1;+function processEffectSchedule(runtime: Runtime, round: number): void {+ const expiring = runtime.expireEffectsByRound[round];+ if (expiring) {+ for (const effect of expiring) expireActiveEffect(runtime, effect);+ runtime.expireEffectsByRound[round] = undefined;+ }+ const activating = runtime.activateEffectsByRound[round];+ if (activating) {+ for (const effect of activating) {+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);}+ runtime.activateEffectsByRound[round] = undefined;}- runtime.activeEffects.length = write;- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));}-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {- if (!passive) return;+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {+ const effects: ActiveEffect[] = [];+ if (!passive) return effects;for (const stat of ["attack", "defense", "lethality", "health"] as const) {for (const direction of ["up", "down"] as const) {const valuePct = Number(passive[stat]?.[direction] ?? 0);if (!Number.isFinite(valuePct) || valuePct <= 0) continue;const bucket = `passive.${stat}.${direction}`;- addActiveEffect(runtime, {+ const effect: ActiveEffect = {id: `${side}:input_stat:${bucket}`,source: {kind: "input_stat",@@ -682,17 +753,23 @@},ownerSide: side,kind: "modifier",- valuePct,+ bucketIndex: -1,+ initialValuePct: valuePct,+ getCurrentValuePct: constantActiveEffectValuePct,appliesTo: { side, units: ALL_UNIT_MASK },appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"- });+ };+ addActiveEffect(runtime, effect);+ effects.push(effect);}}+ return effects;}function triggerSkills(@@ -705,7 +782,8 @@const activated: ActiveEffect[] = [];for (const skill of skills) {if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;- const report = runtime.skillReports[skill.side].get(reportKey(skill));+ const reports = runtime.skillReports[skill.side];+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));if (report) report.triggersSeen += 1;if (!chancePasses(skill, runtime.rng)) continue;if (report) report.skillActivations += 1;@@ -732,12 +810,12 @@let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);if (!defenderUnit) continue;const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;if (ordered) {- ordered.effect.uses += 1;+ chargeEffectUse(runtime, ordered.effect);orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });}const previousAttackCount = runtime.counters.attacks[side][attackerUnit];@@ -772,7 +850,7 @@effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;return firstLivingUnit(order, defenderSide, roundStartTroops);}@@ -784,13 +862,14 @@attackerUnit: UnitType,attackerSide: SideId,index: EffectIndex,- round: number+ advanceAttackDelay: boolean): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;+ if (effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;@@ -802,23 +881,34 @@function applicableControls(job: DamageJob,- round: number,runtime: Runtime-): { dodge?: Control; no_attack?: Control } {- const controls: { dodge?: Control; no_attack?: Control } = {};+): ApplicableControls {+ const controls: ApplicableControls = { attackDurationEffects: [] };for (const effect of runtime.effectIndex.controls) {- if (!isEffectActive(effect, round)) continue;const classification = classifyEffectForJob(effect, job);if (classification?.kind === "control" && classification.control) {- controls[classification.control] = { effect, reason: classification.control };+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);+ controls[classification.control] = {+ effect,+ reason: classification.control,+ attackDurationEffects: controls.attackDurationEffects+ };}}return controls;}+interface ApplicableControls {+ dodge?: Control;+ no_attack?: Control;+ attackDurationEffects: ActiveEffect[];+}+interface Control {effect: ActiveEffect;reason: "dodge" | "no_attack";+ attackDurationEffects: ActiveEffect[];}function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {@@ -847,7 +937,9 @@const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),+ runtime.effectIndex.extraAttacks.filter(+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)+ ),round);for (const effectGroup of effectGroups) {@@ -916,7 +1008,7 @@}const existing = selected[existingIndex];existing.effects.push(effect);- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;}return selected;}@@ -979,7 +1071,7 @@}function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;const pct = Number(raw ?? 0);return Number.isFinite(pct) ? pct / 100 : 0;}@@ -1054,24 +1146,38 @@// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.function chargeUsedEffects(runtime: Runtime): void {- if (runtime.usedEffects.size === 0) return;- for (const effect of runtime.usedEffects) effect.uses += 1;- runtime.usedEffects.clear();+ if (runtime.usedEffects.length === 0) return;+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);+ runtime.usedEffects.length = 0;+}++function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {+ effect.uses += 1;+ const limit = effectAttackUseLimit(effect);+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);+}++function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {+ if (effect.expired) return;+ expireEffectIndex(runtime.effectIndex, effect);}// A cancelled attack still charges the attacker's attack-constrained effects (the attack// happened, it just didn't land) plus the control that cancelled it, whatever its duration.-function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {+function chargeCancelledAttack(+ job: DamageJob,+ winningControl: ActiveEffect,+ controlEffects: ActiveEffect[],+ runtime: Runtime+): void {const used = runtime.usedEffects;- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);- }- for (const effect of runtime.effectIndex.controls) {- if (!hasAttackDurationConstraint(effect)) continue;- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") used.add(effect);+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) used.push(effect);}- used.add(winningControl);+ for (const effect of controlEffects) used.push(effect);+ if (!controlEffects.includes(winningControl)) used.push(winningControl);chargeUsedEffects(runtime);}diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts--- a/simulator/src/staticDamageProfile.ts+++ b/simulator/src/staticDamageProfile.ts@@ -1,7 +1,7 @@import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";-import { currentEffectValuePct, sourceLabel } from "./effects";+import { sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;@@ -11,6 +11,7 @@export interface StaticDamageProfileEntry {factor: number;+ playerFactorsValid: boolean;buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;}@@ -90,7 +91,7 @@const duration = effect.duration;if (duration === undefined) return;- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {+ if (duration.turns !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}}@@ -130,7 +131,7 @@{ effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }]);}- return { factor: 1, buckets };+ return { factor: 1, playerFactorsValid: true, buckets };}function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {@@ -142,7 +143,7 @@const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];for (const unit of UNIT_TYPES) {if (!unitMaskHas(effect.appliesTo.units, unit)) continue;- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };+ const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };if (effect.sameEffectStacking === "max" && effect.stackingKey) {const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;const group = groups.get(key);@@ -177,12 +178,20 @@function recomputeFactors(profile: StaticDamageProfile): void {for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {- profile.offense[side][unit].factor = offenseFactor(profile.offense[side][unit]);- profile.defense[side][unit].factor = defenseFactor(profile.defense[side][unit]);+ const offense = profile.offense[side][unit];+ const defense = profile.defense[side][unit];+ offense.factor = offenseFactor(offense);+ defense.factor = defenseFactor(defense);+ offense.playerFactorsValid = playerPctFactorValid(offense, "player.attack") && playerPctFactorValid(offense, "player.lethality");+ defense.playerFactorsValid = playerPctFactorValid(defense, "player.health") && playerPctFactorValid(defense, "player.defense");}}}+function playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {+ return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;+}+interface StaticFactorTerm {bucket: StaticDamageBucket;valueType: BucketValueType;diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -74,11 +74,14 @@export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;-export interface EffectDuration {- type: "battle" | "round" | "attack";- value: number;+export interface EffectDurationAxis {+ count?: number;delay?: number;- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;+}++export interface EffectDuration {+ turns?: EffectDurationAxis;+ attacks?: EffectDurationAxis;}export interface EffectIntentDefinition {@@ -88,11 +91,7 @@value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: {- turns?: { count?: number; delay?: number };- rounds?: { count?: number; delay?: number };- attacks?: { count?: number; delay?: number };- };+ duration?: EffectDuration;same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -205,6 +204,14 @@level: number;trigger: TriggerDefinition;effects: EffectIntentDefinition[];+ compiledTrigger?: {+ definition: TriggerDefinition;+ side: SideId;+ level: number;+ source: ResolvedUnitScope;+ target: ResolvedUnitScope;+ probabilityPct: number;+ };}export interface ResolvedHero {@@ -242,11 +249,16 @@export interface ActiveEffect {id: string;+ expired?: boolean;source: EffectSource;intent: EffectIntentDefinition;ownerSide: SideId;kind: ActiveEffectKind;- valuePct?: number;+ // Numeric slot in the runtime damage scratch. Dynamic modifiers receive it+ // when indexed; non-damage effects keep -1.+ bucketIndex: number;+ initialValuePct: number;+ getCurrentValuePct(round: number): number;// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",// trigger-relative selectors, or concrete unit selectors; it does not accept "all".appliesTo: ResolvedUnitScope;@@ -255,6 +267,9 @@createdRound: number;startRound: number;duration: EffectDuration;+ // Eligible attacks remaining before an attacks.delay effect can apply. This is+ // runtime state resolved from config, separate from `uses` after activation.+ remainingAttackDelay: number;// Times this instance affected battle mechanics (damage bucket applied, control fired,// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"// value evolution read it; cancelled attacks still charge attack-constrained effects@@ -264,6 +279,14 @@sameEffectStacking: SameEffectStacking;}+export interface EvolvingActiveEffect extends ActiveEffect {+ valueEvolution: {+ type?: string;+ step?: string;+ amount: number;+ };+}+export interface AttackIntent {id: string;round: number;
Run B dirty state patch
diff --git a/simulator/src/classifier.ts b/simulator/src/classifier.ts--- a/simulator/src/classifier.ts+++ b/simulator/src/classifier.ts@@ -1,11 +1,10 @@import type { ActiveEffect, DamageJob, SideId, UnitType } from "./types";import { unitMaskHas } from "./types";-import { bucketDefinition, type BucketName, type StaticPassiveBucket } from "./damageBuckets";-import { staticPassiveBucketRole } from "./staticDamageProfile";+import { bucketDefinition, passiveBucketRole, type BucketName, type PassiveBucket } from "./damageBuckets";export interface Classification {kind: "bucket" | "control" | "extra_skill_attack" | "battle_order" | "report_only";- bucket?: BucketName | StaticPassiveBucket;+ bucket?: BucketName | PassiveBucket;control?: "dodge" | "no_attack";reason?: string;}@@ -22,18 +21,18 @@if (type === "attack_order") return { kind: "battle_order" };const definition = bucketDefinition(type);- const staticPassiveRole = staticPassiveBucketRole(type);- if ((!definition || definition.valueType !== "pct") && !staticPassiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };- if (staticPassiveRole) {- if (staticPassiveRole === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };- if (staticPassiveRole === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: type as StaticPassiveBucket };+ const passiveRole = passiveBucketRole(type);+ if ((!definition || definition.valueType !== "pct") && !passiveRole) return { kind: "report_only", reason: unsupportedReason(effect, job) };+ if (passiveRole) {+ if (passiveRole === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: type as PassiveBucket };+ if (passiveRole === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: type as PassiveBucket };return { kind: "report_only", reason: unsupportedReason(effect, job) };}if (!definition) return { kind: "report_only", reason: unsupportedReason(effect, job) };if (definition.phase !== "dynamic") return { kind: "report_only", reason: unsupportedReason(effect, job) };if (definition.appliesTo !== undefined && definition.appliesTo !== job.kind) return { kind: "report_only", reason: "not_applicable_to_job_kind" };- if (definition.role === "attacker" && effect.appliesTo.side === job.attackerSide) return { kind: "bucket", bucket: definition.path };- if (definition.role === "defender" && effect.appliesTo.side === job.defenderSide) return { kind: "bucket", bucket: definition.path };+ if (definition.role === "dealer" && effect.appliesTo.side === job.dealerSide) return { kind: "bucket", bucket: definition.path };+ if (definition.role === "taker" && effect.appliesTo.side === job.takerSide) return { kind: "bucket", bucket: definition.path };return { kind: "report_only", reason: unsupportedReason(effect, job) };}@@ -46,23 +45,23 @@}function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {- const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;- const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;+ const appliesToSide = control === "no_attack" ? job.dealerSide : job.takerSide;+ const appliesToUnit = control === "no_attack" ? job.dealerUnit : job.takerUnit;if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;- const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;- const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;+ const appliesVsSide = control === "no_attack" ? job.takerSide : job.dealerSide;+ const appliesVsUnit = control === "no_attack" ? job.takerUnit : job.dealerUnit;return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);}function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {- if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";- if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";+ if (effect.appliesTo.side === job.dealerSide) return "unsupported_dealer_effect";+ if (effect.appliesTo.side === job.takerSide) return "unsupported_taker_effect";return "wrong_side";}function unitForSide(side: SideId, job: DamageJob): UnitType | undefined {- if (side === job.attackerSide) return job.attackerUnit;- if (side === job.defenderSide) return job.defenderUnit;+ if (side === job.dealerSide) return job.dealerUnit;+ if (side === job.takerSide) return job.takerUnit;return undefined;}diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts--- a/simulator/src/classifierDamage.test.ts+++ b/simulator/src/classifierDamage.test.ts@@ -2,11 +2,11 @@import { test } from "node:test";import { classifyEffectForJob } from "./classifier";-import { calculateDamageJob } from "./damage";-import { ATOMIC_BUCKETS, STATIC_PASSIVE_BUCKETS } from "./damageBuckets";+import { calculateDamageJob, createDamageScratch, evaluateDamageExpression } from "./damage";+import { ATOMIC_BUCKETS, DYNAMIC_BUCKETS, PASSIVE_BUCKETS, STATIC_BUCKETS, type BucketPlacement, type BucketRole } from "./damageBuckets";import { createEffectIndex, damageShapeSlotsForEffect, indexEffect } from "./effectIndex";import { activateEffect, evolvingActiveEffectValuePct, resolvedEffectScopeKey } from "./effects";-import { buildStaticDamageProfile } from "./staticDamageProfile";+import { buildStaticDamageBucketFactors, buildStaticDamageProfile } from "./staticDamageProfile";import { createRecorder, type BattleRecorder } from "./recorder";import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";import { ALL_UNIT_MASK, unitMask } from "./types";@@ -20,10 +20,10 @@attacker: { infantry: 1000, lancer: 0, marksman: 0 },defender: { infantry: 0, lancer: 1000, marksman: 0 }},- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",sourceMultiplier: 1};@@ -52,7 +52,7 @@classifyEffectForJob(active, {...job,id: "job-attacker-marksman",- attackerUnit: "marksman"+ dealerUnit: "marksman"})?.reason,"not_applicable_to_job");@@ -60,7 +60,7 @@classifyEffectForJob(active, {...job,id: "job-defender-marksman",- defenderUnit: "marksman"+ takerUnit: "marksman"})?.reason,"not_applicable_to_job");@@ -100,7 +100,7 @@damageJob: DamageJob,fighters: Record<"attacker" | "defender", ResolvedFighter>,effects: ActiveEffect[],- options: Omit<Partial<Parameters<typeof calculateDamageJob>[3]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}+ options: Omit<Partial<Parameters<typeof calculateDamageJob>[2]>, "recorder"> & { recorder?: BattleRecorder; trace?: boolean } = {}) {const effectIndex = options.effectIndex ?? preparedEffectIndex(effects);if (!options.effectIndex) {@@ -113,8 +113,9 @@const recorder = options.recorder ?? createRecorder(options.trace === true ? "trace" : "standard", [], () => {throw new Error("damage-only test recorder has no battle resolution");});+ recorder.recordStaticProfile(fighters, effects);const { trace: _trace, ...damageOptions } = options;- const result = calculateDamageJob(damageJob, fighters, effects, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });+ const result = calculateDamageJob(damageJob, fighters, { ...damageOptions, recorder, effectIndex, staticDamageProfile, usedEffects });return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };}@@ -222,7 +223,7 @@const ownerSide = type.includes(".attack.") || type.includes(".lethality.") ? "attacker" : "defender";const bucket = classifyEffectForJob(effect(type, ownerSide), job)?.bucket;assert.equal(bucket, expectedBucket, type);- assert.ok(bucket !== undefined && (STATIC_PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a static passive bucket`);+ assert.ok(bucket !== undefined && (PASSIVE_BUCKETS as readonly string[]).includes(bucket), `${type} route ${bucket} is not a passive bucket`);}assert.equal(classifyEffectForJob(effect("passive.attack.down", "attacker", 5), job)?.bucket, "passive.attack.down");@@ -232,22 +233,22 @@const reversedJob: DamageJob = {...job,id: "job-reversed",- attackerSide: "defender",- attackerUnit: "lancer",- defenderSide: "attacker",- defenderUnit: "infantry"+ dealerSide: "defender",+ dealerUnit: "lancer",+ takerSide: "attacker",+ takerUnit: "infantry"};const passiveAttack = effect("passive.attack.up", "attacker", 10);const passiveHealth = effect("passive.health.up", "defender", 10);assert.equal(classifyEffectForJob(passiveAttack, job)?.bucket, "passive.attack.up");- assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_defender_effect");+ assert.equal(classifyEffectForJob(passiveAttack, reversedJob)?.reason, "unsupported_taker_effect");assert.equal(classifyEffectForJob(passiveHealth, job)?.bucket, "passive.health.up");- assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_attacker_effect");+ assert.equal(classifyEffectForJob(passiveHealth, reversedJob)?.reason, "unsupported_dealer_effect");});test("damage calculator requires indexed effect candidates", () => {- assert.throws(() => calculateDamageJob(job, simpleFighters(), [], { trace: true } as never), /effectIndex/i);+ assert.throws(() => calculateDamageJob(job, simpleFighters(), { trace: true } as never), /effectIndex/i);});test("damage calculator uses centralized bucket definitions for player stat routing", () => {@@ -332,12 +333,12 @@assert.equal(outcome.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);assert.equal(outcome.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.3);- assert.equal(outcome.trace?.aggregationGroups["passive.attacker.attack.down"].factor, 1.05);+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.3);+ assert.equal(outcome.trace?.aggregationGroups["passive.dealer.attack.down"].factor, 1.05);assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), Number((1.3 / 1.05).toFixed(6)));});-test("static damage profile produces the same static bucket damage as per-job passive processing", () => {+test("static profile factoring is identical to evaluating its buckets unfactored", () => {const fighters = simpleFighters();fighters.attacker.statBonuses.infantry.attack = 50;fighters.attacker.statBonuses.infantry.lethality = 25;@@ -352,18 +353,89 @@effect("passive.health.down", "defender", 30),effect("passive.defense.up", "defender", 5)];- const baseline = calculateIndexedDamageJob(job, fighters, passives, { trace: true });- const profile = buildStaticDamageProfile(fighters, passives);- const profiled = calculateIndexedDamageJob(job, fighters, passives, { trace: true, staticDamageProfile: profile });+ const runtimeEffects = [+ effect("active.hero.attack.up", "attacker", 15),+ effect("active.troop.damage.down", "attacker", 12),+ effect("active.hero.health.down", "defender", 8),+ effect("type.normal.damageTaken.up", "defender", 7)+ ];+ const allEffects = [...passives, ...runtimeEffects];+ const bucketFactors = buildStaticDamageBucketFactors(fighters, allEffects);+ const profile = buildStaticDamageProfile(fighters, allEffects);+ const scratch = createDamageScratch();+ const profiled = calculateIndexedDamageJob(job, fighters, allEffects, {+ trace: true,+ staticDamageProfile: profile,+ scratch,+ capToTakerTroops: false+ });+ const dealerBuckets = bucketFactors[job.dealerSide][job.dealerUnit];+ const takerBuckets = bucketFactors[job.takerSide][job.takerUnit];+ const dealer = profile[job.dealerSide][job.dealerUnit];+ const taker = profile[job.takerSide][job.takerUnit];- assert.ok(Math.abs(profiled.kills - baseline.kills) < 1e-12);+ assert.equal(+ dealer.dealerFactor.factors[0],+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") /+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator")+ );+ assert.equal(+ taker.takerFactor.factors[0],+ unfactoredStaticProduct(takerBuckets, "taker", "numerator") /+ unfactoredStaticProduct(takerBuckets, "taker", "denominator")+ );+ assert.equal(+ evaluateDamageExpression(dealer.dealerFactor, taker.takerFactor).factors[0],+ dealer.dealerFactor.factors[0] * taker.takerFactor.factors[0]+ );+ const unfactoredStaticNumerator =+ unfactoredStaticProduct(dealerBuckets, "dealer", "numerator") *+ unfactoredStaticProduct(takerBuckets, "taker", "numerator");+ const unfactoredStaticDenominator =+ unfactoredStaticProduct(dealerBuckets, "dealer", "denominator") *+ unfactoredStaticProduct(takerBuckets, "taker", "denominator");+ const unfactoredDamage =+ unfactoredDynamicProduct(scratch.factors, job.kind, "numerator") * unfactoredStaticNumerator /+ (100 * unfactoredDynamicProduct(scratch.factors, job.kind, "denominator") * unfactoredStaticDenominator);++ assert.ok(Math.abs(profiled.kills - unfactoredDamage) < 1e-12);assert.equal(profiled.trace?.atomicBuckets["player.attack"].totalPct, 50);assert.equal(profiled.trace?.atomicBuckets["passive.attack.up"].totalPct, 20);assert.equal(profiled.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);assert.equal(profiled.trace?.atomicBuckets["passive.health.down"].totalPct, 30);- assert.equal(profiled.trace?.aggregationGroups["passive.attacker.attack.up"].factor, 1.2);+ assert.equal(profiled.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 15);+ assert.equal(profiled.trace?.atomicBuckets["type.normal.damageTaken.up"].totalPct, 7);+ assert.equal(profiled.trace?.aggregationGroups["passive.dealer.attack.up"].factor, 1.2);});+function unfactoredStaticProduct(+ factors: Float64Array,+ role: BucketRole,+ placement: BucketPlacement+): number {+ return STATIC_BUCKETS.reduce(+ (product, definition, index) =>+ definition.role === role && definition.placement === placement ? product * factors[index] : product,+ 1+ );+}++function unfactoredDynamicProduct(+ factors: Float64Array,+ kind: DamageJob["kind"],+ placement: BucketPlacement+): number {+ return DYNAMIC_BUCKETS.reduce(+ (product, definition, index) => {+ const appliesTo = "appliesTo" in definition ? definition.appliesTo : undefined;+ return definition.placement === placement && (!appliesTo || appliesTo === kind)+ ? product * factors[index]+ : product;+ },+ 1+ );+}+test("static damage profile preserves max stacking for duplicate passive effects", () => {const fighters = simpleFighters();const weaker = { ...effect("passive.attack.up", "attacker", 20), id: "weak", stackingKey: "attacker:skill:passive", sameEffectStacking: "max" as const };@@ -430,8 +502,8 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [passiveHealthDown], { trace: true });assert.equal(outcome.trace?.atomicBuckets["passive.health.down"].totalPct, 105);- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].factor, 2.05);- assert.equal(outcome.trace?.aggregationGroups["passive.defender.health.down"].placement, "numerator");+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].factor, 2.05);+ assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);});@@ -598,7 +670,7 @@};const fighters = simpleFighters();const infantryOutcome = calculateIndexedDamageJob(job, fighters, [infantry, lancer], { trace: true });- const lancerOutcome = calculateIndexedDamageJob({ ...job, id: "lancer-job", attackerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });+ const lancerOutcome = calculateIndexedDamageJob({ ...job, id: "lancer-job", dealerUnit: "lancer" }, fighters, [infantry, lancer], { trace: true });assert.equal(infantryOutcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 40);assert.deepEqual(infantryOutcome.usedEffectIds, ["max-infantry"]);@@ -630,10 +702,10 @@id: "intent",round: 1,source: "normal",- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,@@ -671,10 +743,10 @@id: "intent",round: 1,source: "normal",- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,@@ -711,10 +783,10 @@id: "intent",round: 1,source: "normal",- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer",+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer",orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,diff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts--- a/simulator/src/config.test.ts+++ b/simulator/src/config.test.ts@@ -177,7 +177,7 @@assert.throws(() => loadSimulatorConfigFromDir(root), /negative.*active\.hero\.attack\.down.*value/i);});-test("loadSimulatorConfig rejects passive effects that are not battle-start static", () => {+test("loadSimulatorConfig rejects effects targeting static buckets unless they are fully static", () => {const turnRoot = writeConfigWithTroopEffect({type: "passive.attack.up",value: 10@@ -198,6 +198,14 @@},{ type: "battle_start" });+ const emptyDurationRoot = writeConfigWithTroopEffect(+ {+ type: "passive.attack.up",+ value: 10,+ duration: {}+ },+ { type: "battle_start" }+ );const probabilityRoot = writeConfigWithTroopEffect({type: "passive.attack.up",@@ -206,10 +214,25 @@{ type: "battle_start", probability: 50 });- assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /passive.*battle_start/i);- assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /passive.*battle.*duration/i);- assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /passive.*value_evolution/i);- assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /passive.*probability.*deterministic/i);+ assert.throws(() => loadSimulatorConfigFromDir(turnRoot), /static bucket passive\.attack\.up.*battle_start/i);+ assert.throws(() => loadSimulatorConfigFromDir(durationRoot), /static bucket passive\.attack\.up.*duration/i);+ assert.throws(() => loadSimulatorConfigFromDir(emptyDurationRoot), /static bucket passive\.attack\.up.*duration/i);+ assert.throws(() => loadSimulatorConfigFromDir(evolvingRoot), /static bucket passive\.attack\.up.*value_evolution/i);+ assert.throws(() => loadSimulatorConfigFromDir(probabilityRoot), /static bucket passive\.attack\.up.*probability/i);+});++test("loadSimulatorConfig rejects skill effects targeting input-derived static buckets", () => {+ const playerRoot = writeConfigWithTroopEffect(+ { type: "player.attack", value: 10 },+ { type: "battle_start" }+ );+ const troopsRoot = writeConfigWithTroopEffect(+ { type: "troops.baseAttack", value: 10 },+ { type: "battle_start" }+ );++ assert.throws(() => loadSimulatorConfigFromDir(playerRoot), /input-derived static bucket/i);+ assert.throws(() => loadSimulatorConfigFromDir(troopsRoot), /input-derived static bucket/i);});test("loadSimulatorConfig rejects invalid trigger_damage_jobs selector strings", () => {diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -37,12 +37,11 @@import { UNIT_TYPES } from "./types";import type { ConfigDiagnostics, EffectIntentDefinition, SimulatorConfig, SkillFile, TriggerDamageJobDefinition } from "./types";-import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, STATIC_PASSIVE_BUCKETS } from "./damageBuckets";-import { assertStaticPassiveEffectDefinition, isPassiveBucket } from "./staticDamageProfile";+import { bucketDefinition, DYNAMIC_EFFECT_BUCKETS, isPassiveBucket, PASSIVE_BUCKETS } from "./damageBuckets";const KNOWN_EFFECT_TYPES = new Set([...DYNAMIC_EFFECT_BUCKETS,- ...STATIC_PASSIVE_BUCKETS,+ ...PASSIVE_BUCKETS,"extra_skill_attack","dodge","no_attack",@@ -180,7 +179,7 @@validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);- assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);+ validateStaticBucketEffect(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);if (!KNOWN_EFFECT_TYPES.has(type)) {diagnostics.unsupportedEffects.push({@@ -261,6 +260,38 @@);}+// Static buckets are aggregated once per battle into the static damage profile, so any effect+// that feeds one must itself be known at battle start, deterministic, immutable, and permanent.+// Static buckets outside the passive family (troops/player) are input-derived and cannot be+// produced by a skill at all.+function validateStaticBucketEffect(+ trigger: SkillFile["skills"][string]["trigger"],+ effect: EffectIntentDefinition,+ file: string,+ skillId: string,+ effectId: string+): void {+ const definition = bucketDefinition(effect.type);+ if (definition?.phase !== "static") return;++ const path = `${file}:${skillId}.${effectId}`;+ if (!isPassiveBucket(effect.type)) {+ throw new Error(`effect type ${effect.type} targets an input-derived static bucket and cannot be defined by a skill at ${path}`);+ }+ if (trigger.type !== "battle_start") {+ throw new Error(`effect targeting static bucket ${effect.type} must use a battle_start trigger at ${path}`);+ }+ if (trigger.probability !== undefined) {+ throw new Error(`effect targeting static bucket ${effect.type} cannot define trigger probability at ${path}`);+ }+ if (effect.value_evolution !== undefined) {+ throw new Error(`effect targeting static bucket ${effect.type} cannot define value_evolution at ${path}`);+ }+ if (effect.duration !== undefined) {+ throw new Error(`effect targeting static bucket ${effect.type} cannot define duration at ${path}`);+ }+}+function validateNativeEffectValue(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {const definition = bucketDefinition(effect.type);if ((!definition || definition.valueType !== "pct") && !isPassiveBucket(effect.type)) return;diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -1,7 +1,6 @@import type {ActiveEffect,ActiveEffectGroup,- DamageBucketTrace,DamageEquationTrace,DamageJob,ResolvedFighter,@@ -9,21 +8,29 @@UnitType} from "./types";import { UNIT_TYPES } from "./types";-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket, type StaticDamageBucket } from "./damageBuckets";+import {+ ATOMIC_BUCKETS,+ DYNAMIC_BUCKETS,+ EVALUATED_DAMAGE_BUCKET,+ STATIC_BUCKETS,+ pctBucketDelta,+ pctBucketFactor,+ rawBucketFactor,+ type AtomicBucket,+ type AggregateBucketSpec,+ type BucketRole,+ type BucketSpec,+ type DamageAggregationBucketSpec,+ type StaticDamageBucket+} from "./damageBuckets";import { advanceEffectAttackDelay } from "./effects";-import { damageJobSlot, type EffectIndex } from "./effectIndex";+import { damageJobShapeSlot, damageJobSlot, type EffectIndex } from "./effectIndex";import type { BattleRecorder, DamageJobRecorder } from "./recorder";-import {- buildStaticDamageProfile,- type StaticDamageProfile,- type StaticDamageProfileEntry-} from "./staticDamageProfile";type NumericBucketId = number;interface DamageFactorTerm {- id: string;bucket: NumericBucketId;- bucketName: AtomicBucket;+ role?: BucketRole;placement: "numerator" | "denominator";appliesTo?: DamageJob["kind"];}@@ -32,11 +39,31 @@factors: Float64Array;}+interface CompiledDamageExpression {+ numeratorSlots: Int32Array;+ denominatorSlots: Int32Array;+}++export interface DamageBucketSet {+ factors: Float64Array;+ expression: CompiledDamageExpression;+}+export type DamageScratch = NumericDamageBuckets;+export type StaticDamageBucketFactors = Float64Array;+export type StaticDamageBucketMatrix = Record<SideId, Record<UnitType, StaticDamageBucketFactors>>;++/** Pre-collapsed substitutions for one fighter's possible roles in a damage job. */+export interface StaticDamageProfileEntry {+ dealerFactor: DamageBucketSet;+ takerFactor: DamageBucketSet;+}++export type StaticDamageProfile = Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;// Lean result of a single damage job: kills plus whatever the selected recorder requested.// Usage charging happens via options.usedEffects (every mode); the heavy per-attack-// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.+// AttackOutcome is assembled by the recorder, not here.export interface DamageResult {kills: number;appliedEffects?: DamageEquationTrace["appliedEffects"];@@ -44,72 +71,42 @@}const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;-const TROOPS_COUNT_INDEX = BUCKET_IDS["troops.count"];-const SOURCE_EXTRA_SKILL_INDEX = BUCKET_IDS["source.extraSkill"];-const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));-const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");-const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");-const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");-const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;-const factorSlots = (terms: DamageFactorTerm[], kind: DamageJob["kind"]): Int32Array =>- Int32Array.from(terms.filter((term) => !term.appliesTo || term.appliesTo === kind).map((term) => term.bucket));-const DEFAULT_NUMERATOR_SLOTS = { normal: factorSlots(DEFAULT_NUMERATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_NUMERATOR_TERMS, "skill") };-const DEFAULT_DENOMINATOR_SLOTS = { normal: factorSlots(DEFAULT_DENOMINATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_DENOMINATOR_TERMS, "skill") };-const PROFILED_NUMERATOR_SLOTS = { normal: factorSlots(PROFILED_NUMERATOR_TERMS, "normal"), skill: factorSlots(PROFILED_NUMERATOR_TERMS, "skill") };-const PROFILED_DENOMINATOR_SLOTS = DEFAULT_DENOMINATOR_SLOTS;-const BUCKET_UPDATE_BY_INDEX = Uint8Array.from(- ATOMIC_BUCKETS.map((bucket) => {- const update = BUCKET_DEFINITIONS[bucket].update;- return update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2;- })-);--export class DamageAggregationError extends Error {- readonly groupId: string;- readonly round: number;- readonly jobId: string;- readonly netPct: number | undefined;- readonly factor: number;- readonly contributors: DamageBucketTrace["contributors"];-- constructor(args: {- groupId: string;- round: number;- jobId: string;- netPct?: number;- factor: number;- contributors: DamageBucketTrace["contributors"];- }) {- super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor}${args.netPct === undefined ? "" : ` netPct=${args.netPct}`}`);- this.name = "DamageAggregationError";- this.groupId = args.groupId;- this.round = args.round;- this.jobId = args.jobId;- this.netPct = args.netPct;- this.factor = args.factor;- this.contributors = args.contributors;- }-}+const STATIC_BUCKET_IDS = Object.fromEntries(STATIC_BUCKETS.map((bucket, index) => [bucket.path, index])) as Record<StaticDamageBucket, NumericBucketId>;+const DYNAMIC_FACTOR_TERMS = compileDamageTerms(DYNAMIC_BUCKETS);+const STATIC_FACTOR_TERMS = compileDamageTerms(STATIC_BUCKETS);+const DYNAMIC_EXPRESSIONS = {+ normal: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "normal" }),+ skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" })+};+const STATIC_EXPRESSIONS = {+ dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "dealer" }),+ taker: compileDamageExpression(STATIC_FACTOR_TERMS, { role: "taker" })+};+const EVALUATED_EXPRESSION = compileDamageExpression(compileDamageTerms([EVALUATED_DAMAGE_BUCKET]), {});+const BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(DYNAMIC_BUCKETS);+const STATIC_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates(STATIC_BUCKETS);+const EVALUATED_BUCKET_UPDATE_BY_INDEX = compileBucketUpdates([EVALUATED_DAMAGE_BUCKET]);+const DAMAGE_SCALE_BUCKET = createEvaluatedDamageBucket(1 / 100);export function calculateDamageJob(job: DamageJob,fighters: Record<SideId, ResolvedFighter>,- activeEffects: ActiveEffect[],- options: { recorder: BattleRecorder; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }+ options: { recorder: BattleRecorder; effectIndex: EffectIndex; staticDamageProfile: StaticDamageProfile; scratch?: DamageScratch; capToTakerTroops?: boolean; usedEffects?: ActiveEffect[] }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");if (!options.recorder) throw new Error("calculateDamageJob requires a recorder");+ if (!options.staticDamageProfile) throw new Error("calculateDamageJob requires a staticDamageProfile");const recording = options.recorder.startDamageJob();- const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);- const attacker = fighters[job.attackerSide];- const defender = fighters[job.defenderSide];- const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;- const defenderTroops = job.roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0;- const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));- const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));+ const staticProfile = options.staticDamageProfile;+ const dealer = fighters[job.dealerSide];+ const taker = fighters[job.takerSide];+ const dealerTroops = job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0;+ const takerTroops = job.roundStartTroops[job.takerSide][job.takerUnit] ?? 0;+ const minInitialArmy = Math.max(1, Math.min(totalTroops(dealer.initialTroops), totalTroops(taker.initialTroops)));+ const armyTerm = Math.ceil(Math.sqrt(Math.max(0, dealerTroops)) * Math.sqrt(minInitialArmy));const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();- buckets.factors[TROOPS_COUNT_INDEX] = Math.max(0, armyTerm);- buckets.factors[SOURCE_EXTRA_SKILL_INDEX] = Math.max(0, job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);+ applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);+ applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);const usedEffects = options.usedEffects ?? [];applyBucketEffects(@@ -120,15 +117,10 @@usedEffects);- const staticOffense = staticProfile.offense[job.attackerSide][job.attackerUnit];- const staticDefense = staticProfile.defense[job.defenderSide][job.defenderUnit];- recording.recordStaticProfile(staticOffense);- recording.recordStaticProfile(staticDefense);-- const rawDamage = evaluateDefaultDamageExpression(job, buckets, staticProfile);+ const rawDamage = evaluateDamageExpressionForJob(job, buckets, staticProfile);const uncappedKills = Math.max(0, rawDamage);- const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);- return recording.finish({ job, factors: buckets.factors, staticOffense, staticDefense, armyTerm, rawDamage, kills });+ const kills = options.capToTakerTroops === false ? uncappedKills : Math.min(takerTroops, uncappedKills);+ return recording.finish({ job, factors: buckets.factors, armyTerm, rawDamage, kills });}function applyBucketEffects(@@ -178,10 +170,7 @@): number {const appliedValuePct = selected.getCurrentValuePct(round);const bucketIndex = selected.bucketIndex;- const update = BUCKET_UPDATE_BY_INDEX[bucketIndex];- if (update === 0) buckets.factors[bucketIndex] = Math.max(0, appliedValuePct);- else if (update === 1) buckets.factors[bucketIndex] *= 1 + appliedValuePct / 100;- else buckets.factors[bucketIndex] += appliedValuePct / 100;+ applyBucketValue(buckets.factors, bucketIndex, appliedValuePct, BUCKET_UPDATE_BY_INDEX);recording.recordModifier(selected, appliedValuePct);return appliedValuePct;}@@ -223,17 +212,59 @@}}-function factorTerm(bucket: AtomicBucket): DamageFactorTerm {- const definition = BUCKET_DEFINITIONS[bucket];- return {- id: bucket,- bucket: BUCKET_IDS[bucket],- bucketName: bucket,+function compileDamageTerms(definitions: readonly (BucketSpec | AggregateBucketSpec)[]): DamageFactorTerm[] {+ return definitions.map((definition, bucket) => ({+ bucket,+ role: "role" in definition ? definition.role : undefined,placement: definition.placement,appliesTo: definition.appliesTo+ }));+}++function compileDamageExpression(+ terms: DamageFactorTerm[],+ selection: {+ role?: BucketRole;+ kind?: DamageJob["kind"];+ }+): CompiledDamageExpression {+ const selected = terms+ .filter((term) => selection.role === undefined || term.role === selection.role)+ .filter((term) => selection.kind === undefined || !term.appliesTo || term.appliesTo === selection.kind);+ return {+ numeratorSlots: Int32Array.from(selected.filter((term) => term.placement === "numerator").map((term) => term.bucket)),+ denominatorSlots: Int32Array.from(selected.filter((term) => term.placement === "denominator").map((term) => term.bucket))};}+function compileBucketUpdates(definitions: readonly DamageAggregationBucketSpec[]): Uint8Array {+ return Uint8Array.from(+ definitions.map(({ update, valueType }) =>+ valueType === "factor" ? 3 : update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2+ )+ );+}++function applyBucketValue(factors: Float64Array, bucket: number, value: number, updates: Uint8Array): void {+ const update = updates[bucket];+ if (update === 0) factors[bucket] = rawBucketFactor(value);+ else if (update === 1) factors[bucket] *= pctBucketFactor(value);+ else if (update === 2) factors[bucket] += pctBucketDelta(value);+ else factors[bucket] = value;+}++function applyDynamicDamageBucketValue(buckets: NumericDamageBuckets, bucket: AtomicBucket, value: number): number {+ const bucketIndex = BUCKET_IDS[bucket];+ applyBucketValue(buckets.factors, bucketIndex, value, BUCKET_UPDATE_BY_INDEX);+ return buckets.factors[bucketIndex];+}++function multiplySlots(initial: number, factors: Float64Array, slots: Int32Array): number {+ let product = initial;+ for (const slot of slots) product *= factors[slot];+ return product;+}+function createNumericDamageBuckets(): NumericDamageBuckets {const factors = new Float64Array(ATOMIC_BUCKETS.length);factors.fill(1);@@ -244,61 +275,64 @@return createNumericDamageBuckets();}+/** Creates neutral factors for the closed/static bucket pool. */+export function createStaticDamageBucketFactors(): StaticDamageBucketFactors {+ const factors = new Float64Array(STATIC_BUCKETS.length);+ factors.fill(1);+ return factors;+}++/** Populates a static bucket with exactly the same metadata-driven update rule as a live bucket. */+export function applyStaticDamageBucketValue(+ factors: StaticDamageBucketFactors,+ bucket: StaticDamageBucket,+ value: number+): number {+ const bucketIndex = STATIC_BUCKET_IDS[bucket];+ applyBucketValue(factors, bucketIndex, value, STATIC_BUCKET_UPDATE_BY_INDEX);+ return factors[bucketIndex];+}++/** Selects one role's static buckets as an ordinary input to the standard evaluator. */+export function staticDamageBucketSet(factors: StaticDamageBucketFactors, role: BucketRole): DamageBucketSet {+ return { factors, expression: STATIC_EXPRESSIONS[role] };+}+function resetDamageScratch(buckets: DamageScratch): DamageScratch {buckets.factors.fill(1);return buckets;}-function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): number {- if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, staticProfile);- const factors = buckets.factors;- let numerator = 1;- for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];- let denominator = 1;- for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];- return numerator / denominator;+/** Pure, composable reduction of bucket sets to an ordinary one-value bucket set. */+export function evaluateDamageExpression(...inputs: DamageBucketSet[]): DamageBucketSet {+ let factor = 1;+ for (const input of inputs) {+ const numerator = multiplySlots(1, input.factors, input.expression.numeratorSlots);+ const denominator = multiplySlots(1, input.factors, input.expression.denominatorSlots);+ factor *= numerator / denominator;+ }+ return createEvaluatedDamageBucket(factor);+}++function createEvaluatedDamageBucket(factor: number): DamageBucketSet {+ const factors = new Float64Array(1);+ applyBucketValue(factors, 0, factor, EVALUATED_BUCKET_UPDATE_BY_INDEX);+ return { factors, expression: EVALUATED_EXPRESSION };}-function evaluateProfiledDamageExpression(+function evaluateDamageExpressionForJob(job: DamageJob,buckets: NumericDamageBuckets,staticProfile: StaticDamageProfile): number {- const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];- const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];- if (!offense.playerFactorsValid || !defense.playerFactorsValid) validateProfiledStaticFactors(job, staticProfile);- const factors = buckets.factors;- let numerator =- factors[TROOPS_COUNT_INDEX] *- factors[SOURCE_EXTRA_SKILL_INDEX] *- offense.factor *- defense.factor;- for (const slot of PROFILED_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];- let denominator = 100;- for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];- return numerator / denominator;-}--function validateProfiledStaticFactors(job: DamageJob, staticProfile: StaticDamageProfile): void {- validateProfiledPctFactor(job, "player.attacker.attack", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.attack");- validateProfiledPctFactor(job, "player.attacker.lethality", staticProfile.offense[job.attackerSide][job.attackerUnit], "player.lethality");- validateProfiledPctFactor(job, "player.defender.health", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.health");- validateProfiledPctFactor(job, "player.defender.defense", staticProfile.defense[job.defenderSide][job.defenderUnit], "player.defense");-}--function validateProfiledPctFactor(job: DamageJob, groupId: string, entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): void {- const term = entry.buckets[bucket];- const totalPct = term?.totalPct ?? 0;- const factor = 1 + totalPct / 100;- if (factor > 0) return;- throw new DamageAggregationError({- groupId,- round: job.round,- jobId: job.id,- netPct: totalPct,- factor,- contributors: term?.contributors ?? []- });+ const dealerFactor = staticProfile[job.dealerSide][job.dealerUnit].dealerFactor;+ const takerFactor = staticProfile[job.takerSide][job.takerUnit].takerFactor;+ return evaluateDamageExpression(+ { factors: buckets.factors, expression: DYNAMIC_EXPRESSIONS[job.kind] },+ dealerFactor,+ takerFactor,+ DAMAGE_SCALE_BUCKET+ ).factors[0];}function totalTroops(troops: Record<UnitType, number>): number {diff --git a/simulator/src/damageBuckets.ts b/simulator/src/damageBuckets.ts--- a/simulator/src/damageBuckets.ts+++ b/simulator/src/damageBuckets.ts@@ -1,81 +1,98 @@import type { DamageKind } from "./types";-export type BucketRole = "attacker" | "defender";-export type BucketValueType = "raw" | "pct";+export type BucketRole = "dealer" | "taker";+export type BucketValueType = "raw" | "pct" | "factor";export type BucketUpdate = "assign_factor" | "add_pct_factor" | "multiply_pct_factor";export type BucketPlacement = "numerator" | "denominator";-type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source";+type BucketFamily = "troops" | "player" | "passive" | "active" | "type" | "source" | "aggregate";-interface BucketSpec {+export interface DamageAggregationBucketSpec {path: string;- family: BucketFamily;- role: BucketRole;valueType: BucketValueType;update: BucketUpdate;placement: BucketPlacement;appliesTo?: DamageKind;}+export interface BucketSpec extends DamageAggregationBucketSpec {+ family: Exclude<BucketFamily, "aggregate">;+ role: BucketRole;+}++export interface AggregateBucketSpec extends DamageAggregationBucketSpec {+ family: "aggregate";+}++// The result of evaluating any bucket set is represented as this ordinary one-value bucket.+// It has no dealer/taker role because its input set has already resolved that distinction.+export const EVALUATED_DAMAGE_BUCKET = {+ path: "aggregate.factor",+ family: "aggregate",+ valueType: "factor",+ update: "assign_factor",+ placement: "numerator"+} as const satisfies AggregateBucketSpec;+// Closed pools: every contributor is known while preparing the battle, so these// buckets are aggregated once into StaticDamageProfile factors.export const STATIC_BUCKETS = [- { path: "troops.baseAttack", family: "troops", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },- { path: "troops.baseLethality", family: "troops", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },- { path: "troops.baseHealth", family: "troops", role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },- { path: "troops.baseDefense", family: "troops", role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },- { path: "player.attack", family: "player", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "player.lethality", family: "player", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "player.health", family: "player", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "player.defense", family: "player", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "passive.attack.up", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "passive.attack.down", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "passive.lethality.up", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "passive.lethality.down", family: "passive", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "passive.health.up", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "passive.health.down", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "passive.defense.up", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "passive.defense.down", family: "passive", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ { path: "troops.baseAttack", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },+ { path: "troops.baseLethality", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },+ { path: "troops.baseHealth", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },+ { path: "troops.baseDefense", family: "troops", role: "taker", valueType: "raw", update: "assign_factor", placement: "denominator" },+ { path: "player.attack", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "player.lethality", family: "player", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "player.health", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "player.defense", family: "player", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.attack.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "passive.attack.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.lethality.up", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "passive.lethality.down", family: "passive", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.health.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.health.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "passive.defense.up", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "passive.defense.down", family: "passive", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" }] as const satisfies readonly BucketSpec[];// Open pools: runtime effects can feed these buckets while the battle is running.// Array order is the numeric slot order used by the fast damage scratch.export const DYNAMIC_BUCKETS = [- { path: "active.hero.attack.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.hero.attack.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.hero.damage.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.hero.damage.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.hero.damageTaken.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.hero.damageTaken.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.hero.defense.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.hero.defense.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.hero.health.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.hero.health.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.hero.lethality.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.hero.lethality.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.troop.attack.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.troop.attack.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.troop.damage.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.troop.damage.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.troop.damageTaken.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.troop.damageTaken.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.troop.defense.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.troop.defense.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.troop.health.down", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "active.troop.health.up", family: "active", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.troop.lethality.down", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },- { path: "active.troop.lethality.up", family: "active", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },- { path: "source.extraSkill", family: "source", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" },- { path: "troops.count", family: "troops", role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },- { path: "type.all.damage.down", family: "type", role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" },- { path: "type.all.damage.up", family: "type", role: "attacker", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },- { path: "type.normal.damage.down", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },- { path: "type.normal.damage.up", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },- { path: "type.normal.damageTaken.down", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },- { path: "type.normal.damageTaken.up", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },- { path: "type.skill.damage.down", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },- { path: "type.skill.damage.up", family: "type", role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },- { path: "type.skill.damageTaken.down", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },- { path: "type.skill.damageTaken.up", family: "type", role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }+ { path: "active.hero.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.hero.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.hero.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.attack.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.attack.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.damage.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.damage.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.damageTaken.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.damageTaken.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.defense.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.defense.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.health.down", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "active.troop.health.up", family: "active", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.lethality.down", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ { path: "active.troop.lethality.up", family: "active", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ { path: "source.extraSkill", family: "source", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator", appliesTo: "skill" },+ { path: "troops.count", family: "troops", role: "dealer", valueType: "raw", update: "assign_factor", placement: "numerator" },+ { path: "type.all.damage.down", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "denominator" },+ { path: "type.all.damage.up", family: "type", role: "dealer", valueType: "pct", update: "multiply_pct_factor", placement: "numerator" },+ { path: "type.normal.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },+ { path: "type.normal.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },+ { path: "type.normal.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "normal" },+ { path: "type.normal.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "normal" },+ { path: "type.skill.damage.down", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },+ { path: "type.skill.damage.up", family: "type", role: "dealer", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" },+ { path: "type.skill.damageTaken.down", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "denominator", appliesTo: "skill" },+ { path: "type.skill.damageTaken.up", family: "type", role: "taker", valueType: "pct", update: "add_pct_factor", placement: "numerator", appliesTo: "skill" }] as const satisfies readonly BucketSpec[];export type StaticBucketSpec = (typeof STATIC_BUCKETS)[number];@@ -85,10 +102,18 @@export type BucketName = AtomicBucket;export type StaticRawBucket = Extract<StaticBucketSpec, { valueType: "raw" }>["path"];export type StaticPlayerBucket = Extract<StaticBucketSpec, { family: "player" }>["path"];-export type StaticPassiveBucket = Extract<StaticBucketSpec, { family: "passive" }>["path"];+export type PassiveBucket = Extract<StaticBucketSpec, { family: "passive" }>["path"];export type BucketPath = StaticDamageBucket | AtomicBucket;-type BucketDefinitionFields = Omit<BucketSpec, "path">;+type NativeBucketSpec = StaticBucketSpec | DynamicBucketSpec;+type BucketDefinitionFields = {+ family: NativeBucketSpec["family"];+ role: NativeBucketSpec["role"];+ valueType: NativeBucketSpec["valueType"];+ update: NativeBucketSpec["update"];+ placement: NativeBucketSpec["placement"];+ appliesTo?: DamageKind;+};export type BucketDefinition =| (BucketDefinitionFields & { path: StaticDamageBucket; phase: "static" })| (BucketDefinitionFields & { path: AtomicBucket; phase: "dynamic" });@@ -97,9 +122,9 @@export const DYNAMIC_EFFECT_BUCKETS = DYNAMIC_BUCKETS.filter((bucket) => bucket.family === "active" || bucket.family === "type").map((bucket) => bucket.path) as AtomicBucket[];-export const STATIC_PASSIVE_BUCKETS = STATIC_BUCKETS+export const PASSIVE_BUCKETS = STATIC_BUCKETS.filter((bucket): bucket is Extract<StaticBucketSpec, { family: "passive" }> => bucket.family === "passive")- .map((bucket) => bucket.path) as StaticPassiveBucket[];+ .map((bucket) => bucket.path) as PassiveBucket[];const ALL_BUCKET_DEFINITIONS: BucketDefinition[] = [...STATIC_BUCKETS.map((bucket) => ({ ...bucket, phase: "static" as const })),@@ -113,3 +138,27 @@export function bucketDefinition(path: string): BucketDefinition | undefined {return BUCKET_DEFINITIONS[path as BucketPath];}++export function isPassiveBucket(bucket: string): bucket is PassiveBucket {+ const definition = BUCKET_DEFINITIONS[bucket as PassiveBucket];+ return definition?.phase === "static" && definition.family === "passive";+}++export function passiveBucketRole(bucket: string): BucketRole | undefined {+ return isPassiveBucket(bucket) ? BUCKET_DEFINITIONS[bucket].role : undefined;+}++// The one and only bucket-value conversion. A raw bucket contributes its value clamped+// non-negative; a pct bucket contributes its percentage delta or the corresponding factor.+// Every consumer (static profile, dynamic scratch, recorders) must use these.+export function rawBucketFactor(raw: number): number {+ return Math.max(0, raw);+}++export function pctBucketDelta(totalPct: number): number {+ return totalPct / 100;+}++export function pctBucketFactor(totalPct: number): number {+ return 1 + pctBucketDelta(totalPct);+}diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts--- a/simulator/src/effectIndex.test.ts+++ b/simulator/src/effectIndex.test.ts@@ -38,10 +38,10 @@attacker: { infantry: 100, lancer: 0, marksman: 0 },defender: { infantry: 0, lancer: 100, marksman: 0 }},- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer"+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer"};assert.deepEqual(index.damageGroupsByJobShape[damageJobSlot(job)].flatMap((group) => group.effects), [effect]);@@ -128,9 +128,9 @@attacker: { infantry: 100, lancer: 0, marksman: 0 },defender: { infantry: 0, lancer: 100, marksman: 0 }},- attackerSide: "attacker",- attackerUnit: "infantry",- defenderSide: "defender",- defenderUnit: "lancer"+ dealerSide: "attacker",+ dealerUnit: "infantry",+ takerSide: "defender",+ takerUnit: "lancer"};}diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts--- a/simulator/src/effectIndex.ts+++ b/simulator/src/effectIndex.ts@@ -86,17 +86,17 @@}export function damageJobSlot(job: DamageJob): number {- return damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit);+ return damageJobShapeSlot(job.kind, job.dealerSide, job.dealerUnit, job.takerSide, job.takerUnit);}export function damageJobShapeSlot(jobKind: DamageKind,- attackerSide: SideId,- attackerUnit: UnitType,- defenderSide: SideId,- defenderUnit: UnitType+ dealerSide: SideId,+ dealerUnit: UnitType,+ takerSide: SideId,+ takerUnit: UnitType): number {- return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);+ return (((kindIndex(jobKind) * 2 + sideIndex(dealerSide)) * 3 + unitIndex(dealerUnit)) * 2 + sideIndex(takerSide)) * 3 + unitIndex(takerUnit);}const JOB_SHAPE_CACHE = new Map<number, Uint8Array>();@@ -133,7 +133,7 @@for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {slots.push(- definition.role === "attacker"+ definition.role === "dealer"? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit): damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit));diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -57,8 +57,8 @@if (!intent) return true;const selectors = compiledTriggerForSkill(skill);return (- triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&- triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit)+ triggerScopeMatches(selectors.source, intent.dealerSide, intent.dealerUnit) &&+ triggerScopeMatches(selectors.target, intent.takerSide, intent.takerUnit));}@@ -315,10 +315,10 @@throw new Error('effect units.applies_vs cannot be "all"; use "any" for an unrestricted usage gate');}if ((value === "trigger.source" || value === "trigger") && attackIntent) {- return { side: attackIntent.attackerSide, units: unitMask(attackIntent.attackerUnit) };+ return { side: attackIntent.dealerSide, units: unitMask(attackIntent.dealerUnit) };}if ((value === "trigger.target" || value === "target") && attackIntent) {- return { side: attackIntent.defenderSide, units: unitMask(attackIntent.defenderUnit) };+ return { side: attackIntent.takerSide, units: unitMask(attackIntent.takerUnit) };}if (ownerSide && isRelationQualifiedSelector(value)) {const selector = parseTriggerSelector(value, "self");diff --git a/simulator/src/index.ts b/simulator/src/index.ts--- a/simulator/src/index.ts+++ b/simulator/src/index.ts@@ -1,12 +1,11 @@export { buildSimulatorConfig, loadSimulatorConfig } from "./config";export type { RawSimulatorConfig } from "./config";-export { bearFighterInput, prepareBattle, runPrepared, simulateBearBattle, signedRemainingScore } from "./simulator";+export { prepareBattle, runPrepared, simulateBattles } from "./simulator";export type { CompiledBattle } from "./simulator";export { BattleInputBuilder } from "./battleInputBuilder";export { applyHeroGenerationStats } from "./resolve";export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";export { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";export { classifyEffectForJob } from "./classifier";-export { calculateDamageJob } from "./damage";export type { CalibrationCaseComparison, CalibrationComparison, CalibrationComparisonRow } from "./calibration";export type * from "./types";diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts--- a/simulator/src/recorder.ts+++ b/simulator/src/recorder.ts@@ -1,7 +1,8 @@import type { DamageResult } from "./damage";-import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket, type StaticDamageBucket } from "./damageBuckets";+import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, pctBucketFactor, rawBucketFactor, type AtomicBucket, type StaticDamageBucket, type StaticPlayerBucket, type StaticRawBucket } from "./damageBuckets";import { sourceLabel } from "./effects";-import type { StaticDamageProfileEntry } from "./staticDamageProfile";+import { selectPassiveContributions, unitBaseStats, unitPlayerBonuses, type PassiveContribution } from "./staticDamageProfile";+import { UNIT_TYPES } from "./types";import type {ActiveEffect,AppliedEffect,@@ -35,8 +36,6 @@export interface DamageRecordingResult {job: DamageJob;factors: Float64Array;- staticOffense: StaticDamageProfileEntry;- staticDefense: StaticDamageProfileEntry;armyTerm: number;rawDamage: number;kills: number;@@ -46,15 +45,46 @@export interface DamageJobRecorder {recordModifier(effect: ActiveEffect, valuePct: number): void;recordRejected(effect: ActiveEffect, reason: RejectedEffectReason): void;- recordStaticProfile(entry: StaticDamageProfileEntry): void;finish(result: DamageRecordingResult): DamageResult;}+// Recorder-side description of how the static damage factors were assembled: the same selection+// the numeric profile uses (via selectPassiveContributions) expanded into per-bucket terms with+// contributors. The battle runtime never sees these shapes; fast mode never builds them.+interface StaticProfileTerm {+ raw?: number;+ totalPct?: number;+ factor: number;+ contributors: DamageBucketTrace["contributors"];+}++interface StaticProfileEntry {+ buckets: Partial<Record<StaticDamageBucket, StaticProfileTerm>>;+}++interface StaticProfileDescription {+ dealer: Record<SideId, Record<UnitType, StaticProfileEntry>>;+ taker: Record<SideId, Record<UnitType, StaticProfileEntry>>;+}++// Per-(side, unit) applied-effect fragments prebuilt from the static profile description; the+// per-job recorders splice them into each damage event instead of re-walking the description.+interface StaticAppliedIndex {+ dealer: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;+ taker: Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;+}+/*** Observes the battle without deciding combat behavior. The concrete recorder is selected once,* before the round loop, and every simulation path emits the same observations to it.*/export interface BattleRecorder {+ /**+ * Receives the battle's fighters and setup effects before any damage job runs. Recorders that+ * report static contributions build their description of the static profile here; the null+ * recorder does nothing, so fast mode pays nothing.+ */+ recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void;startDamageJob(): DamageJobRecorder;recordSkillTriggerAttempt(skill: ResolvedSkill): void;recordSkillTriggered(skill: ResolvedSkill): void;@@ -80,7 +110,6 @@const NULL_DAMAGE_JOB_RECORDER: DamageJobRecorder = {recordModifier() {},recordRejected() {},- recordStaticProfile() {},finish({ kills }) {return { kills };}@@ -95,6 +124,7 @@return NULL_DAMAGE_JOB_RECORDER;}+ recordStaticProfile() {}recordBattleOrder() {}recordSkillTriggerAttempt() {}recordSkillTriggered() {}@@ -122,6 +152,7 @@export class BasicInfoRecorder implements BattleRecorder {readonly attacks: AttackOutcome[] = [];+ protected staticApplied?: StaticAppliedIndex;protected readonly orderEvents = new Map<string, AppliedEffect>();protected readonly extraAttackEvents = new Map<string, AppliedEffect[]>();protected readonly skillReports: Record<SideId, Map<string, SkillReportEntry>> = {@@ -161,8 +192,19 @@};}+ recordStaticProfile(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): void {+ this.applyStaticDescription(buildStaticProfileDescription(fighters, setupEffects));+ }++ protected applyStaticDescription(description: StaticProfileDescription): void {+ this.staticApplied = {+ dealer: staticAppliedFragments(description.dealer),+ taker: staticAppliedFragments(description.taker)+ };+ }+startDamageJob(): DamageJobRecorder {- return new BasicDamageJobRecorder();+ return new BasicDamageJobRecorder(this.staticApplied);}recordSkillTriggerAttempt(_skill: ResolvedSkill): void {}@@ -222,10 +264,10 @@jobId: `${intent.id}:cancelled`,round: intent.round,kind: "normal",- attackerSide: intent.attackerSide,- attackerUnit: intent.attackerUnit,- defenderSide: intent.defenderSide,- defenderUnit: intent.defenderUnit,+ dealerSide: intent.dealerSide,+ dealerUnit: intent.dealerUnit,+ takerSide: intent.takerSide,+ takerUnit: intent.takerUnit,kills: 0,counterDeltas: counterDeltas(intent, "normal_attack"),appliedEffects: order@@ -240,7 +282,7 @@const sourceSkillReportKey = this.skillDamageKeys.get(job.id);this.skillDamageKeys.delete(job.id);if (job.kind === "skill" && sourceSkillReportKey && result.kills > 0) {- const report = this.skillReports[job.attackerSide].get(sourceSkillReportKey);+ const report = this.skillReports[job.dealerSide].get(sourceSkillReportKey);if (report) report.skillKills += result.kills;}const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";@@ -254,10 +296,10 @@kind: job.kind,sourceEffectId: job.sourceEffectId,sourceSkillReportKey,- attackerSide: job.attackerSide,- attackerUnit: job.attackerUnit,- defenderSide: job.defenderSide,- defenderUnit: job.defenderUnit,+ dealerSide: job.dealerSide,+ dealerUnit: job.dealerUnit,+ takerSide: job.takerSide,+ takerUnit: job.takerUnit,kills: result.kills,counterDeltas: counterDeltas(job, cause),appliedEffects: mergeAppliedEffects(result.appliedEffects, order, extras),@@ -282,14 +324,20 @@export class FullTraceRecorder extends BasicInfoRecorder {private readonly rounds: BattleTrace["rounds"] = [];private readonly roundJobs: DamageJob[] = [];+ private staticDescription?: StaticProfileDescription;constructor(fighters: ResolvedFighter[],private readonly makeResolved: () => BattleTrace["resolved"]) { super(fighters); }+ protected override applyStaticDescription(description: StaticProfileDescription): void {+ super.applyStaticDescription(description);+ this.staticDescription = description;+ }+override startDamageJob(): DamageJobRecorder {- return new FullDamageJobRecorder();+ return new FullDamageJobRecorder(this.staticApplied, this.staticDescription);}override recordSkillTriggerAttempt(skill: ResolvedSkill): void {@@ -318,6 +366,8 @@class BasicDamageJobRecorder implements DamageJobRecorder {protected readonly appliedEffects: DamageEquationTrace["appliedEffects"] = [];+ constructor(private readonly staticApplied?: StaticAppliedIndex) {}+recordModifier(effect: ActiveEffect, valuePct: number): void {if (valuePct === 0) return;const applied = {@@ -336,27 +386,19 @@recordRejected(_effect: ActiveEffect, _reason: RejectedEffectReason): void {}- recordStaticProfile(entry: StaticDamageProfileEntry): void {- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {- if (!bucket.startsWith("passive.") || !term) continue;- for (const contributor of term.contributors) {- this.appliedEffects.push({- kind: "modifier",- activeEffectId: contributor.effectId,- effectId: contributor.effectId,- bucket,- valuePct: contributor.valuePct,- source: contributor.source,- sourceSide: contributor.sourceSide,- stackingKey: contributor.stackingKey,- sameEffectStacking: contributor.sameEffectStacking- });- }- }+ finish({ job, kills }: DamageRecordingResult): DamageResult {+ return { kills, appliedEffects: this.appliedEffectsFor(job) };}- finish({ kills }: DamageRecordingResult): DamageResult {- return { kills, appliedEffects: this.appliedEffects };+ // Dynamic modifiers first, then the prebuilt static passive fragments for the job's+ // participants — the same order the per-job walk used to produce.+ protected appliedEffectsFor(job: DamageJob): DamageEquationTrace["appliedEffects"] {+ const statics = this.staticApplied;+ if (!statics) return this.appliedEffects;+ const dealer = statics.dealer[job.dealerSide][job.dealerUnit];+ const taker = statics.taker[job.takerSide][job.takerUnit];+ if (dealer.length === 0 && taker.length === 0) return this.appliedEffects;+ return [...this.appliedEffects, ...dealer, ...taker];}}@@ -367,6 +409,11 @@);private readonly rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];+ constructor(+ staticApplied?: StaticAppliedIndex,+ private readonly staticDescription?: StaticProfileDescription+ ) { super(staticApplied); }+override recordModifier(effect: ActiveEffect, valuePct: number): void {this.contributors[effect.bucketIndex]?.push({effectId: effect.source.effectId ?? effect.id,@@ -386,18 +433,21 @@override finish(result: DamageRecordingResult): DamageResult {const basic = super.finish(result);- const staticEntries = [result.staticOffense, result.staticDefense];+ const job = result.job;+ const staticEntries = this.staticDescription+ ? [this.staticDescription.dealer[job.dealerSide][job.dealerUnit], this.staticDescription.taker[job.takerSide][job.takerUnit]]+ : [];return {...basic,trace: {roundStartTroops: {- attacker: { ...result.job.roundStartTroops.attacker },- defender: { ...result.job.roundStartTroops.defender }+ attacker: { ...job.roundStartTroops.attacker },+ defender: { ...job.roundStartTroops.defender }},armyTerm: result.armyTerm,atomicBuckets: toTraceBuckets(result.factors, this.contributors, staticEntries),- aggregationGroups: buildAggregationGroups(result.job, result.factors, this.contributors, staticEntries),- appliedEffects: this.appliedEffects,+ aggregationGroups: buildAggregationGroups(job, result.factors, this.contributors, staticEntries),+ appliedEffects: basic.appliedEffects ?? [],rejectedEffects: this.rejectedEffects,rawDamage: result.rawDamage,finalKills: result.kills@@ -422,11 +472,117 @@};}+function buildStaticProfileDescription(fighters: Record<SideId, ResolvedFighter>, setupEffects: ActiveEffect[]): StaticProfileDescription {+ const description: StaticProfileDescription = {+ dealer: {+ attacker: buildSideEntries(fighters.attacker, "dealer"),+ defender: buildSideEntries(fighters.defender, "dealer")+ },+ taker: {+ attacker: buildSideEntries(fighters.attacker, "taker"),+ defender: buildSideEntries(fighters.defender, "taker")+ }+ };+ for (const contribution of selectPassiveContributions(setupEffects)) {+ addPassiveTerm(description[contribution.role][contribution.side][contribution.unit], contribution);+ }+ return description;+}++function buildSideEntries(fighter: ResolvedFighter, role: "dealer" | "taker"): Record<UnitType, StaticProfileEntry> {+ return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildStaticEntry(fighter, unit, role)])) as Record<UnitType, StaticProfileEntry>;+}++function buildStaticEntry(fighter: ResolvedFighter, unit: UnitType, role: "dealer" | "taker"): StaticProfileEntry {+ const stats = unitBaseStats(fighter, unit);+ const bonuses = unitPlayerBonuses(fighter, unit);+ const buckets: StaticProfileEntry["buckets"] = {};+ if (role === "dealer") {+ setRawTerm(buckets, "troops.baseAttack", stats.attack);+ setRawTerm(buckets, "troops.baseLethality", stats.lethality);+ setPlayerTerm(buckets, "player.attack", bonuses.attack);+ setPlayerTerm(buckets, "player.lethality", bonuses.lethality);+ } else {+ setRawTerm(buckets, "troops.baseHealth", stats.health);+ setRawTerm(buckets, "troops.baseDefense", stats.defense);+ setPlayerTerm(buckets, "player.health", bonuses.health);+ setPlayerTerm(buckets, "player.defense", bonuses.defense);+ }+ return { buckets };+}++function setRawTerm(buckets: StaticProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {+ buckets[bucket] = { raw, factor: rawBucketFactor(raw), contributors: [] };+}++function setPlayerTerm(buckets: StaticProfileEntry["buckets"], bucket: StaticPlayerBucket, valuePct: number): void {+ buckets[bucket] = {+ totalPct: valuePct,+ factor: pctBucketFactor(valuePct),+ contributors: [{ effectId: `input:${bucket.slice("player.".length)}`, source: "input_stats", valuePct, bucket }]+ };+}++function addPassiveTerm(entry: StaticProfileEntry, contribution: PassiveContribution): void {+ const { bucket, effect, valuePct } = contribution;+ const contributor = {+ effectId: effect.source.effectId ?? effect.id,+ source: sourceLabel(effect),+ sourceSide: effect.ownerSide,+ valuePct,+ bucket,+ stackingKey: effect.stackingKey,+ sameEffectStacking: effect.sameEffectStacking+ };+ const existing = entry.buckets[bucket];+ if (existing) {+ existing.totalPct = (existing.totalPct ?? 0) + valuePct;+ existing.factor = pctBucketFactor(existing.totalPct);+ existing.contributors.push(contributor);+ } else {+ entry.buckets[bucket] = { totalPct: valuePct, factor: pctBucketFactor(valuePct), contributors: [contributor] };+ }+}++function staticAppliedFragments(+ entries: Record<SideId, Record<UnitType, StaticProfileEntry>>+): Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>> {+ return Object.fromEntries(+ (Object.entries(entries) as Array<[SideId, Record<UnitType, StaticProfileEntry>]>).map(([side, units]) => [+ side,+ Object.fromEntries(+ (Object.entries(units) as Array<[UnitType, StaticProfileEntry]>).map(([unit, entry]) => [unit, passiveAppliedEffects(entry)])+ )+ ])+ ) as Record<SideId, Record<UnitType, DamageEquationTrace["appliedEffects"]>>;+}++function passiveAppliedEffects(entry: StaticProfileEntry): DamageEquationTrace["appliedEffects"] {+ const applied: DamageEquationTrace["appliedEffects"] = [];+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {+ if (!bucket.startsWith("passive.") || !term) continue;+ for (const contributor of term.contributors) {+ applied.push({+ kind: "modifier",+ activeEffectId: contributor.effectId,+ effectId: contributor.effectId,+ bucket,+ valuePct: contributor.valuePct,+ source: contributor.source,+ sourceSide: contributor.sourceSide,+ stackingKey: contributor.stackingKey,+ sameEffectStacking: contributor.sameEffectStacking+ });+ }+ }+ return applied;+}+function buildAggregationGroups(job: DamageJob,factors: Float64Array,contributors: DamageBucketTrace["contributors"][],- staticEntries: StaticDamageProfileEntry[]+ staticEntries: StaticProfileEntry[]): Record<string, DamageAggregationGroupTrace> {const groups: Record<string, DamageAggregationGroupTrace> = {};addStaticAggregationGroups(groups, staticEntries);@@ -458,34 +614,34 @@function addStaticAggregationGroups(groups: Record<string, DamageAggregationGroupTrace>,- staticEntries: StaticDamageProfileEntry[]+ staticEntries: StaticProfileEntry[]): void {- const offense = staticEntries[0];- const defense = staticEntries[1];- if (!offense || !defense) return;- addStaticRawGroup(groups, "troops.baseAttack", "numerator", offense, "troops.baseAttack");- addStaticRawGroup(groups, "troops.baseLethality", "numerator", offense, "troops.baseLethality");- addStaticPctGroup(groups, "player.attacker.attack", "numerator", offense, "player.attack");- addStaticPctGroup(groups, "player.attacker.lethality", "numerator", offense, "player.lethality");- addStaticPctGroup(groups, "passive.attacker.attack.up", "numerator", offense, "passive.attack.up");- addStaticPctGroup(groups, "passive.attacker.lethality.up", "numerator", offense, "passive.lethality.up");- addStaticPctGroup(groups, "passive.defender.health.down", "numerator", defense, "passive.health.down");- addStaticPctGroup(groups, "passive.defender.defense.down", "numerator", defense, "passive.defense.down");- addStaticRawGroup(groups, "troops.baseHealth", "denominator", defense, "troops.baseHealth");- addStaticRawGroup(groups, "troops.baseDefense", "denominator", defense, "troops.baseDefense");- addStaticPctGroup(groups, "player.defender.health", "denominator", defense, "player.health");- addStaticPctGroup(groups, "player.defender.defense", "denominator", defense, "player.defense");- addStaticPctGroup(groups, "passive.attacker.attack.down", "denominator", offense, "passive.attack.down");- addStaticPctGroup(groups, "passive.attacker.lethality.down", "denominator", offense, "passive.lethality.down");- addStaticPctGroup(groups, "passive.defender.health.up", "denominator", defense, "passive.health.up");- addStaticPctGroup(groups, "passive.defender.defense.up", "denominator", defense, "passive.defense.up");+ const dealer = staticEntries[0];+ const taker = staticEntries[1];+ if (!dealer || !taker) return;+ addStaticRawGroup(groups, "troops.baseAttack", "numerator", dealer, "troops.baseAttack");+ addStaticRawGroup(groups, "troops.baseLethality", "numerator", dealer, "troops.baseLethality");+ addStaticPctGroup(groups, "player.dealer.attack", "numerator", dealer, "player.attack");+ addStaticPctGroup(groups, "player.dealer.lethality", "numerator", dealer, "player.lethality");+ addStaticPctGroup(groups, "passive.dealer.attack.up", "numerator", dealer, "passive.attack.up");+ addStaticPctGroup(groups, "passive.dealer.lethality.up", "numerator", dealer, "passive.lethality.up");+ addStaticPctGroup(groups, "passive.taker.health.down", "numerator", taker, "passive.health.down");+ addStaticPctGroup(groups, "passive.taker.defense.down", "numerator", taker, "passive.defense.down");+ addStaticRawGroup(groups, "troops.baseHealth", "denominator", taker, "troops.baseHealth");+ addStaticRawGroup(groups, "troops.baseDefense", "denominator", taker, "troops.baseDefense");+ addStaticPctGroup(groups, "player.taker.health", "denominator", taker, "player.health");+ addStaticPctGroup(groups, "player.taker.defense", "denominator", taker, "player.defense");+ addStaticPctGroup(groups, "passive.dealer.attack.down", "denominator", dealer, "passive.attack.down");+ addStaticPctGroup(groups, "passive.dealer.lethality.down", "denominator", dealer, "passive.lethality.down");+ addStaticPctGroup(groups, "passive.taker.health.up", "denominator", taker, "passive.health.up");+ addStaticPctGroup(groups, "passive.taker.defense.up", "denominator", taker, "passive.defense.up");}function addStaticRawGroup(groups: Record<string, DamageAggregationGroupTrace>,id: string,placement: GroupPlacement,- entry: StaticDamageProfileEntry,+ entry: StaticProfileEntry,bucket: StaticDamageBucket): void {const term = entry.buckets[bucket];@@ -494,7 +650,7 @@mode: "raw",placement,inputBuckets: [bucket],- factor: Math.max(0, term?.raw ?? 0),+ factor: term?.factor ?? 0,contributors: term?.contributors ?? []};}@@ -503,18 +659,17 @@groups: Record<string, DamageAggregationGroupTrace>,id: string,placement: GroupPlacement,- entry: StaticDamageProfileEntry,+ entry: StaticProfileEntry,bucket: StaticDamageBucket): void {const term = entry.buckets[bucket];- const totalPct = term?.totalPct ?? 0;groups[id] = {id,mode: "sum_pct",placement,inputBuckets: [bucket],- totalPct,- factor: 1 + totalPct / 100,+ totalPct: term?.totalPct ?? 0,+ factor: term?.factor ?? 1,contributors: term?.contributors ?? []};}@@ -522,7 +677,7 @@function toTraceBuckets(factors: Float64Array,contributors: DamageBucketTrace["contributors"][],- staticEntries: StaticDamageProfileEntry[]+ staticEntries: StaticProfileEntry[]): Record<string, DamageBucketTrace> {const traced = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => {@@ -535,11 +690,11 @@})) as Record<string, DamageBucketTrace>;for (const entry of staticEntries) {- for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {+ for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticProfileEntry["buckets"][StaticDamageBucket]]>) {if (!term) continue;traced[bucket] = term.raw !== undefined- ? { raw: term.raw, factor: Math.max(0, term.raw), contributors: [...term.contributors] }- : { totalPct: term.totalPct ?? 0, factor: 1 + (term.totalPct ?? 0) / 100, contributors: [...term.contributors] };+ ? { raw: term.raw, factor: term.factor, contributors: [...term.contributors] }+ : { totalPct: term.totalPct ?? 0, factor: term.factor, contributors: [...term.contributors] };}}return traced;@@ -555,12 +710,12 @@}function counterDeltas(- attack: Pick<AttackIntent | DamageJob, "attackerSide" | "attackerUnit" | "defenderSide" | "defenderUnit">,+ attack: Pick<AttackIntent | DamageJob, "dealerSide" | "dealerUnit" | "takerSide" | "takerUnit">,cause: "normal_attack" | "extra_skill_attack"): AttackOutcome["counterDeltas"] {return [- { side: attack.attackerSide, unit: attack.attackerUnit, counter: "attacks", by: 1, cause },- { side: attack.defenderSide, unit: attack.defenderUnit, counter: "received_attacks", by: 1, cause }+ { side: attack.dealerSide, unit: attack.dealerUnit, counter: "attacks", by: 1, cause },+ { side: attack.takerSide, unit: attack.takerUnit, counter: "received_attacks", by: 1, cause }];}diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -6,7 +6,7 @@import { loadSimulatorConfig } from "./config";import { createSeededRng, chancePasses } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./resolve";-import { prepareBattle, runPrepared, simulateBearBattle, signedRemainingScore } from "./simulator";+import { prepareBattle, runPrepared, simulateBattles, simulateBearBattle, signedRemainingScore } from "./simulator";import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";function runOnce(input: BattleInput, config: SimulatorConfig, options: SimulationOptions = {}) {@@ -37,6 +37,23 @@assert.deepEqual(prepared.remaining, direct.remaining);});+test("simulateBattles returns the requested replicates and rejects invalid counts", () => {+ const config = loadSimulatorConfig();+ const input: BattleInput = {+ seed: "replicates",+ maxRounds: 1,+ attacker: { troops: { infantry_t1: 1000 }, stats: {}, heroes: {} },+ defender: { troops: { infantry_t1: 1000 }, stats: {}, heroes: {} }+ };++ const results = simulateBattles(input, config, { count: 3, mode: "fast" });++ assert.equal(results.length, 3);+ assert.ok(results.every((result) => result.attacks.length === 0));+ assert.throws(() => simulateBattles(input, config, { count: 0 }), /positive integer/i);+ assert.throws(() => simulateBattles(input, config, { count: 1.5 }), /positive integer/i);+});+test("runPrepared returns structured result for a no-hero battle", () => {const config = loadSimulatorConfig();const result = runOnce(@@ -100,7 +117,7 @@}));- const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");assert.equal(attack?.round, 1);assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);assert.equal(attack?.trace, undefined);@@ -384,7 +401,7 @@{ mode: "trace" });- const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");+ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.dealerSide === "attacker");assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);@@ -587,11 +604,11 @@{ mode: "trace" });- const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");- assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");+ const lancerAttacks = result.attacks.filter((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "lancer");+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.takerUnit, "marksman");+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.takerUnit, "infantry");const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));- assert.equal(round4LancerAttack?.defenderUnit, "marksman");+ assert.equal(round4LancerAttack?.takerUnit, "marksman");assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);});@@ -763,7 +780,7 @@}));- const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");+ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.dealerSide === "attacker");assert.equal(skillAttacks.length, 1);assert.ok(skillAttacks[0].jobId.startsWith("r2:"));});@@ -805,7 +822,7 @@);const normalAttacks = result.attacks.filter(- (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"+ (attack) => attack.kind === "normal" && attack.dealerSide === "attacker");assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);@@ -1064,7 +1081,7 @@["r1:attacker:infantry:0:cancelled"]);assert.equal(- result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.takerSide === "attacker" && attack.takerUnit === "infantry" && attack.cancelReason === "no_attack"),false);});@@ -1106,7 +1123,7 @@["r1:attacker:infantry:0:cancelled"]);assert.equal(- result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),+ result.attacks.some((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "infantry" && attack.cancelReason === "dodge"),false);});@@ -1452,7 +1469,7 @@{ mode: "trace" });- const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ const attack = result.attacks.find((entry) => entry.dealerSide === "attacker" && entry.dealerUnit === "infantry");assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);assert.equal(attack?.trace?.atomicBuckets["passive.attack.down"].totalPct, 5);assert.deepEqual(@@ -1505,7 +1522,7 @@const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),+ skillJobs.map((job) => [job.sourceEffectId, job.takerUnit]),[["hitLancer", "lancer"],["hitMarksman", "marksman"]@@ -1550,7 +1567,7 @@const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => [job.sourceEffectId, job.defenderUnit]),+ skillJobs.map((job) => [job.sourceEffectId, job.takerUnit]),[["hitAny", "lancer"]]);});@@ -1660,7 +1677,7 @@);const defenderLosses = result.attacks- .filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")+ .filter((attack) => attack.takerSide === "defender" && attack.takerUnit === "infantry").reduce((sum, attack) => sum + attack.kills, 0);const doubleBlast = result.skillReport.attacker.find((entry) => entry.skillId === "DoubleBlast");@@ -1705,12 +1722,12 @@{ mode: "trace" });const roundOneInfantryLosses = result.attacks- .filter((attack) => attack.jobId.startsWith("r1:") && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry")+ .filter((attack) => attack.jobId.startsWith("r1:") && attack.takerSide === "attacker" && attack.takerUnit === "infantry").map((attack) => attack.kills);const roundTwo = result.trace?.rounds.find((round) => round.round === 2);const defenderTargets = roundTwo?.intents- .filter((intent) => intent.attackerSide === "defender")- .map((intent) => intent.defenderUnit);+ .filter((intent) => intent.dealerSide === "defender")+ .map((intent) => intent.takerUnit);assert.ok(roundOneInfantryLosses.every((kills) => !Number.isInteger(kills)));assert.equal(roundOneInfantryLosses.reduce((sum, kills) => sum + kills, 0), 10);@@ -1837,10 +1854,10 @@assert.equal(normalJobs.length, 2);assert.equal(skillJobs.length, 1);- assert.equal(skillJobs[0]?.attackerSide, normalJobs[0]?.attackerSide);- assert.equal(skillJobs[0]?.attackerUnit, normalJobs[0]?.attackerUnit);- assert.equal(skillJobs[0]?.defenderSide, normalJobs[0]?.defenderSide);- assert.equal(skillJobs[0]?.defenderUnit, normalJobs[0]?.defenderUnit);+ assert.equal(skillJobs[0]?.dealerSide, normalJobs[0]?.dealerSide);+ assert.equal(skillJobs[0]?.dealerUnit, normalJobs[0]?.dealerUnit);+ assert.equal(skillJobs[0]?.takerSide, normalJobs[0]?.takerSide);+ assert.equal(skillJobs[0]?.takerUnit, normalJobs[0]?.takerUnit);});test("cancelled normal attacks do not consume extra skill attack uses", () => {@@ -1940,7 +1957,7 @@const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => `${job.attackerSide}.${job.attackerUnit}->${job.defenderSide}.${job.defenderUnit}`),+ skillJobs.map((job) => `${job.dealerSide}.${job.dealerUnit}->${job.takerSide}.${job.takerUnit}`),["attacker.marksman->defender.infantry"]);assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });@@ -1995,7 +2012,7 @@const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(- skillJobs.map((job) => job.defenderUnit).sort(),+ skillJobs.map((job) => job.takerUnit).sort(),["infantry", "infantry", "lancer", "marksman"]);assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "MultiTargetShot")?.triggersSeen, 1);@@ -2157,7 +2174,7 @@const jobs = result.trace?.rounds[0]?.jobs ?? [];assert.deepEqual(- jobs.map((job) => `${job.kind}:${job.attackerUnit}->${job.defenderUnit}`),+ jobs.map((job) => `${job.kind}:${job.dealerUnit}->${job.takerUnit}`),["normal:marksman->infantry", "normal:infantry->marksman", "normal:lancer->marksman"]);assert.deepEqual(result.extraSkillAttackJobsByEffect, {});@@ -2252,9 +2269,9 @@{ mode: "trace" });- const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");- const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");- const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");+ const attackerAttack = result.attacks.find((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");+ const defenderLancerAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "lancer");+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "marksman");assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);@@ -2295,9 +2312,9 @@{ mode: "trace" });- const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");- const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");- const defenderMarksmanAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "marksman");+ const attackerAttack = result.attacks.find((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "infantry");+ const defenderLancerAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "lancer");+ const defenderMarksmanAttack = result.attacks.find((attack) => attack.dealerSide === "defender" && attack.dealerUnit === "marksman");assert.equal(attackerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 0);assert.equal(defenderLancerAttack?.trace?.atomicBuckets["active.hero.lethality.down"].totalPct, 50);diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -58,6 +58,9 @@// chargeUsedEffects (uses += 1 each) after every job in every mode.usedEffects: ActiveEffect[];staticDamageProfile?: StaticDamageProfile;+ // Materialized battle_start + input passive effects; recorders derive their own static profile+ // description from these, so the runtime carries no trace-shaped data.+ setupEffects: ActiveEffect[];damageScratch: DamageScratch;rng: Rng;skills: RuntimeSkills;@@ -116,8 +119,8 @@capJobKills: boolean;commitLosses: boolean;scoreSide?: {- attackerSide: SideId;- defenderSide: SideId;+ dealerSide: SideId;+ takerSide: SideId;};}@@ -150,7 +153,7 @@capRoundKills: false,capJobKills: false,commitLosses: false,- scoreSide: { attackerSide: "attacker", defenderSide: "defender" }+ scoreSide: { dealerSide: "attacker", takerSide: "defender" }});return {...buildBattleResult(run),@@ -161,14 +164,16 @@/*** A resolved battle ready to run many times with different seeds. When battle-start is deterministic,* the entire pre-loop runtime (`template`) is seed-independent and built once; each run clones its- * mutable state and reuses the skills + static profile by reference. For stochastic battle-start the- * template is absent and runs reuse only the resolved fighters.+ * mutable state and reuses the skills + static profile by reference. For stochastic battle-start,+ * the template is absent; resolved fighters, skills, and the static profile are still reused while+ * chance-bearing setup state is rebuilt per run.*/export interface CompiledBattle {input: BattleInput;config: SimulatorConfig;fighters: Record<SideId, ResolvedFighter>;template?: Runtime;+ staticProfile: StaticDamageProfile;runtimeSkills: RuntimeSkills;deterministicBattleStart: boolean;}@@ -179,9 +184,16 @@const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };const runtimeSkills = buildRuntimeSkills([attacker, defender]);const deterministicBattleStart = !runtimeSkills.battleStart.some(hasChanceTrigger);- // Deterministic battle-start => the entire pre-loop runtime is seed-independent; build it once.- const template = deterministicBattleStart ? setupRuntime(fighters, input, "simulator-prepare", NULL_RECORDER, runtimeSkills) : undefined;- return { input, config, fighters, template, runtimeSkills, deterministicBattleStart };+ if (deterministicBattleStart) {+ // The entire pre-loop runtime is seed-independent, so build it once and clone it per run.+ const template = setupRuntime(fighters, input, "simulator-prepare", NULL_RECORDER, runtimeSkills);+ return { input, config, fighters, template, staticProfile: template.staticDamageProfile!, runtimeSkills, deterministicBattleStart };+ }+ // Chance-bearing battle-start effects must be rolled per run. Build only the deterministic+ // setup effects that feed static buckets here; do not execute and discard the stochastic setup.+ const staticEffects = materializeStaticSetupEffects(fighters, input, runtimeSkills);+ const staticProfile = buildStaticDamageProfile(fighters, staticEffects);+ return { input, config, fighters, staticProfile, runtimeSkills, deterministicBattleStart };}export function runPrepared(compiled: CompiledBattle, seed?: string | number, options: SimulationOptions = {}): BattleResult {@@ -191,6 +203,27 @@return buildBattleResult(runBattle(runInput, compiled.config, options, compiled));}+/**+ * Prepare the battle once and run `count` replicates, reusing the compiled fighters, skills, and+ * static damage profile across every run. Replicate 0 runs with the input's own seed; replicate+ * `i` runs with `${seed}#${i}` so results are reproducible and distinct.+ */+export function simulateBattles(+ input: BattleInput,+ config: SimulatorConfig,+ options: SimulationOptions & { count?: number } = {}+): BattleResult[] {+ const { count = 1, ...runOptions } = options;+ if (!Number.isInteger(count) || count < 1) {+ throw new Error(`simulateBattles count must be a positive integer, got ${JSON.stringify(count)}`);+ }+ const compiled = prepareBattle(input, config);+ const baseSeed = input.seed ?? "simulator-default";+ return Array.from({ length: count }, (_, index) =>+ runPrepared(compiled, index === 0 ? baseSeed : `${baseSeed}#${index}`, runOptions)+ );+}+function buildBattleResult(run: BattleRun): BattleResult {const { fighters, runtime } = run;return {@@ -220,19 +253,40 @@input: BattleInput,seed: string | number,recorder: BattleRecorder,- runtimeSkills?: RuntimeSkills+ runtimeSkills?: RuntimeSkills,+ prebuiltProfile?: StaticDamageProfile): Runtime {const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), runtimeSkills);const setupEffects = [...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime, recorder),- ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),- ...addInputPassiveEffects(runtime, input.defender.passive, "defender")+ ...createInputPassiveEffects(input.attacker.passive, "attacker"),+ ...createInputPassiveEffects(input.defender.passive, "defender")];- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);+ // Effects feeding static buckets are validated deterministic, so the profile is seed-independent+ // and a prepared build can be reused even when other battle_start skills are stochastic.+ runtime.staticDamageProfile = prebuiltProfile ?? buildStaticDamageProfile(fighters, setupEffects);+ runtime.setupEffects = setupEffects;+ recorder.recordStaticProfile(fighters, setupEffects);runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}+function materializeStaticSetupEffects(+ fighters: Record<SideId, ResolvedFighter>,+ input: BattleInput,+ runtimeSkills: RuntimeSkills+): ActiveEffect[] {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng("simulator-static-profile"), runtimeSkills);+ const staticSkills = runtimeSkills.battleStart.filter((skill) =>+ skill.effects.some((effect) => bucketDefinition(effect.type)?.phase === "static")+ );+ return [+ ...triggerSkills("battle_start", 0, staticSkills, runtime, NULL_RECORDER),+ ...createInputPassiveEffects(input.attacker.passive, "attacker"),+ ...createInputPassiveEffects(input.defender.passive, "defender")+ ];+}+// Deterministic battle-start effects are materialized once in a prepared runtime. Replay only// their observation events for each run; combat state continues to come from the cloned template.function recordPreparedBattleStartSkills(runtime: Runtime, recorder: BattleRecorder): void {@@ -264,6 +318,7 @@expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),usedEffects: [],staticDamageProfile: template.staticDamageProfile,+ setupEffects: template.setupEffects,damageScratch: createDamageScratch(),rng,skills: template.skills,@@ -316,6 +371,7 @@};const recorder = recorderFor(options, fighters);const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ recorder.recordStaticProfile(fighters, runtime.setupEffects);recordPreparedBattleStartSkills(runtime, recorder);return runLoop(input, fighters, runtime, recorder, options, loopOptions);}@@ -323,7 +379,7 @@const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };const recorder = recorderFor(options, fighters);- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills);+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", recorder, prepared?.runtimeSkills, prepared?.staticProfile);return runLoop(input, fighters, runtime, recorder, options, loopOptions);}@@ -348,12 +404,13 @@dodge: options.useEffectsOnDodge ?? true,no_attack: options.useEffectsOnNoAttack ?? true};+ if (!runtime.staticDamageProfile) throw new Error("runLoop requires a runtime with a static damage profile");const damageJobOptions = {recorder,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,+ capToTakerTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects};@@ -377,7 +434,7 @@const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[- damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)+ damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)] ?? [];triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, recorder, intent);const job = normalJob(intent, roundStartTroops);@@ -414,9 +471,9 @@}recorder.recordScheduledDamageJob(job);- const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);+ const normalResult = calculateDamageJob(job, fighters, damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage, recorder);- if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {score += normalResult.kills;}chargeUsedEffects(runtime);@@ -433,7 +490,7 @@for (const extraJob of extraSkill.jobs) {if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;recorder.recordScheduledDamageJob(extraJob);- const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);+ const extraResult = calculateDamageJob(extraJob, fighters, damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage, recorder);processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");processedExtraJobIds.add(extraJob.id);@@ -441,7 +498,7 @@runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;}results.push({ job: extraJob, result: extraResult });- if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {+ if (loopOptions.scoreSide && extraJob.dealerSide === loopOptions.scoreSide.dealerSide && extraJob.takerSide === loopOptions.scoreSide.takerSide) {score += extraResult.kills;}chargeUsedEffects(runtime);@@ -501,16 +558,16 @@activated.push(...triggerSkills("round_start", round, runtime.skills.roundStartGlobal, runtime, recorder));for (const skill of runtime.skills.roundStartPerUnit) {const side = roundTriggerSourceSide(skill);- const defenderSide = roundTriggerTargetSide(skill, side);+ const takerSide = roundTriggerTargetSide(skill, side);if (!skillMatchesTrigger(skill, "round_start", round)) continue;recorder.recordSkillTriggerAttempt(skill);if (!chancePasses(skill, runtime.rng)) continue;recorder.recordSkillTriggered(skill);let orderIndex = 0;- for (const attackerUnit of roundTriggerUnits(skill, roundStartTroops)) {- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);- if (!defenderUnit) continue;- const intent = syntheticRoundIntent(round, side, attackerUnit, defenderSide, defenderUnit, orderIndex, runtime);+ for (const dealerUnit of roundTriggerUnits(skill, roundStartTroops)) {+ const takerUnit = chooseTakerUnit(dealerUnit, side, takerSide, roundStartTroops, runtime.effectIndex);+ if (!takerUnit) continue;+ const intent = syntheticRoundIntent(round, side, dealerUnit, takerSide, takerUnit, orderIndex, runtime);if (!skillMatchesTrigger(skill, "round_start", round, intent)) continue;for (const effectIntent of skill.effects) {const effect = activateEffect(skill, effectIntent, round, intent);@@ -545,26 +602,26 @@function syntheticRoundIntent(round: number,- attackerSide: SideId,- attackerUnit: UnitType,- defenderSide: SideId,- defenderUnit: UnitType,+ dealerSide: SideId,+ dealerUnit: UnitType,+ takerSide: SideId,+ takerUnit: UnitType,orderIndex: number,runtime: Runtime): AttackIntent {return {- id: `r${round}:${attackerSide}:${attackerUnit}:turn:${orderIndex}`,+ id: `r${round}:${dealerSide}:${dealerUnit}:turn:${orderIndex}`,round,source: "normal",- attackerSide,- attackerUnit,- defenderSide,- defenderUnit,+ dealerSide,+ dealerUnit,+ takerSide,+ takerUnit,orderIndex,- previousAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],- projectedAttackCount: runtime.counters.attacks[attackerSide][attackerUnit],- previousReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit],- projectedReceivedAttackCount: runtime.counters.received[defenderSide][defenderUnit]+ previousAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],+ projectedAttackCount: runtime.counters.attacks[dealerSide][dealerUnit],+ previousReceivedAttackCount: runtime.counters.received[takerSide][takerUnit],+ projectedReceivedAttackCount: runtime.counters.received[takerSide][takerUnit]};}@@ -598,6 +655,7 @@expireEffectsByRound: [],usedEffects: [],staticDamageProfile: undefined,+ setupEffects: [],damageScratch: createDamageScratch(),rng,skills,@@ -662,9 +720,9 @@const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });for (const skill of attackDeclared) {const trigger = compiledTriggerForSkill(skill);- for (const attackerUnit of unitsFromMask(trigger.source.units)) {- for (const defenderUnit of unitsFromMask(trigger.target.units)) {- const slot = damageJobShapeSlot("normal", trigger.source.side, attackerUnit, trigger.target.side, defenderUnit);+ for (const dealerUnit of unitsFromMask(trigger.source.units)) {+ for (const takerUnit of unitsFromMask(trigger.target.units)) {+ const slot = damageJobShapeSlot("normal", trigger.source.side, dealerUnit, trigger.target.side, takerUnit);const matching = attackDeclaredByJobShape[slot];if (matching) matching.push(skill);else attackDeclaredByJobShape[slot] = [skill];@@ -707,23 +765,23 @@function potentialActivationIntents(skill: ResolvedSkill): Array<AttackIntent | undefined> {if (skill.trigger.type === "battle_start" || (skill.trigger.type === "turn" && skill.trigger.source === undefined)) return [undefined];const trigger = compiledTriggerForSkill(skill);- const attackerSide = trigger.source.side;- let defenderSide = trigger.target.side;- if (skill.trigger.type === "turn" && defenderSide === attackerSide) defenderSide = oppositeSide(attackerSide);- if (defenderSide === attackerSide) return [];- const attackerUnits = unitsFromMask(trigger.source.units);- const defenderUnits = unitsFromMask(trigger.target.units);+ const dealerSide = trigger.source.side;+ let takerSide = trigger.target.side;+ if (skill.trigger.type === "turn" && takerSide === dealerSide) takerSide = oppositeSide(dealerSide);+ if (takerSide === dealerSide) return [];+ const dealerUnits = unitsFromMask(trigger.source.units);+ const takerUnits = unitsFromMask(trigger.target.units);const intents: AttackIntent[] = [];- for (const attackerUnit of attackerUnits) {- for (const defenderUnit of defenderUnits) {+ for (const dealerUnit of dealerUnits) {+ for (const takerUnit of takerUnits) {intents.push({id: "group-plan",round: 1,source: "normal",- attackerSide,- attackerUnit,- defenderSide,- defenderUnit,+ dealerSide,+ dealerUnit,+ takerSide,+ takerUnit,orderIndex: 0,previousAttackCount: 0,projectedAttackCount: 1,@@ -774,7 +832,7 @@}}-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {+function createInputPassiveEffects(passive: FighterInput["passive"], side: SideId): ActiveEffect[] {const effects: ActiveEffect[] = [];if (!passive) return effects;for (const stat of ["attack", "defense", "lethality", "health"] as const) {@@ -808,7 +866,6 @@uses: 0,sameEffectStacking: "add"};- addActiveEffect(runtime, effect);effects.push(effect);}}@@ -848,28 +905,28 @@): AttackIntent[] {const intents: AttackIntent[] = [];for (const side of ["attacker", "defender"] as SideId[]) {- const defenderSide = oppositeSide(side);+ const takerSide = oppositeSide(side);let orderIndex = 0;- for (const attackerUnit of UNIT_TYPES) {- if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);- const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);- if (!defenderUnit) continue;- const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;+ for (const dealerUnit of UNIT_TYPES) {+ if ((roundStartTroops[side][dealerUnit] ?? 0) <= 0) continue;+ const ordered = orderFromEffects(dealerUnit, side, runtime.effectIndex, true);+ const takerUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, takerSide, roundStartTroops);+ if (!takerUnit) continue;+ const intentId = `r${round}:${side}:${dealerUnit}:${orderIndex}`;if (ordered) {chargeEffectUse(runtime, ordered.effect);- recorder.recordBattleOrder(intentId, ordered.effect, defenderUnit);+ recorder.recordBattleOrder(intentId, ordered.effect, takerUnit);}- const previousAttackCount = runtime.counters.attacks[side][attackerUnit];- const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];+ const previousAttackCount = runtime.counters.attacks[side][dealerUnit];+ const previousReceivedAttackCount = runtime.counters.received[takerSide][takerUnit];intents.push({id: intentId,round,source: "normal",- attackerSide: side,- attackerUnit,- defenderSide,- defenderUnit,+ dealerSide: side,+ dealerUnit,+ takerSide,+ takerUnit,orderIndex,previousAttackCount,projectedAttackCount: previousAttackCount + 1,@@ -884,16 +941,15 @@// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but// does not charge them: no real attack intent is being ordered.-function chooseDefenderUnit(- attackerUnit: UnitType,- attackerSide: SideId,- defenderSide: SideId,+function chooseTakerUnit(+ dealerUnit: UnitType,+ dealerSide: SideId,+ takerSide: SideId,roundStartTroops: DamageJob["roundStartTroops"],- effectIndex: EffectIndex,- round: number+ effectIndex: EffectIndex): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;- return firstLivingUnit(order, defenderSide, roundStartTroops);+ const order = orderFromEffects(dealerUnit, dealerSide, effectIndex, false)?.order ?? UNIT_TYPES;+ return firstLivingUnit(order, takerSide, roundStartTroops);}function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {@@ -901,14 +957,14 @@}function orderFromEffects(- attackerUnit: UnitType,- attackerSide: SideId,+ dealerUnit: UnitType,+ dealerSide: SideId,index: EffectIndex,advanceAttackDelay: boolean): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {if (effect.intent.type !== "attack_order") continue;- if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;+ if (effect.appliesTo.side !== dealerSide || !unitMaskHas(effect.appliesTo.units, dealerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;@@ -960,10 +1016,10 @@kind: "normal",sourceIntentId: intent.id,roundStartTroops,- attackerSide: intent.attackerSide,- attackerUnit: intent.attackerUnit,- defenderSide: intent.defenderSide,- defenderUnit: intent.defenderUnit,+ dealerSide: intent.dealerSide,+ dealerUnit: intent.dealerUnit,+ takerSide: intent.takerSide,+ takerUnit: intent.takerUnit,sourceMultiplier: 1};}@@ -1004,10 +1060,10 @@kind: "skill",sourceIntentId: normalAttack.sourceIntentId,roundStartTroops,- attackerSide: source.side,- attackerUnit: source.unit,- defenderSide: target.side,- defenderUnit: target.unit,+ dealerSide: source.side,+ dealerUnit: source.unit,+ takerSide: target.side,+ takerUnit: target.unit,sourceEffectId,sourceMultiplier: multiplier};@@ -1055,10 +1111,10 @@function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {return (- effect.appliesTo.side === normalAttack.attackerSide &&- unitMaskHas(effect.appliesTo.units, normalAttack.attackerUnit) &&- effect.appliesVs.side === normalAttack.defenderSide &&- unitMaskHas(effect.appliesVs.units, normalAttack.defenderUnit)+ effect.appliesTo.side === normalAttack.dealerSide &&+ unitMaskHas(effect.appliesTo.units, normalAttack.dealerUnit) &&+ effect.appliesVs.side === normalAttack.takerSide &&+ unitMaskHas(effect.appliesVs.units, normalAttack.takerUnit));}@@ -1074,17 +1130,17 @@normalAttack: DamageJob,roundStartTroops: DamageJob["roundStartTroops"]): TriggerJobUnit[] {- if (selector === "use.source") return [{ side: normalAttack.attackerSide, unit: normalAttack.attackerUnit }];- if (selector === "use.target") return [{ side: normalAttack.defenderSide, unit: normalAttack.defenderUnit }];+ if (selector === "use.source") return [{ side: normalAttack.dealerSide, unit: normalAttack.dealerUnit }];+ if (selector === "use.target") return [{ side: normalAttack.takerSide, unit: normalAttack.takerUnit }];if (selector === "effect.applies_to") return unitsFromMask(effect.appliesTo.units).map((unit) => ({ side: effect.appliesTo.side, unit }));if (selector === "effect.applies_vs") return unitsFromMask(effect.appliesVs.units).map((unit) => ({ side: effect.appliesVs.side, unit }));- if (selector === "enemy.living") return livingUnits(normalAttack.defenderSide, roundStartTroops);- if (selector === "self.living") return livingUnits(normalAttack.attackerSide, roundStartTroops);+ if (selector === "enemy.living") return livingUnits(normalAttack.takerSide, roundStartTroops);+ if (selector === "self.living") return livingUnits(normalAttack.dealerSide, roundStartTroops);const units = unitListFromSelector(selector);if (!units) {throw new Error(`trigger_damage_jobs ${role} selector is required and must be a supported selector, got ${JSON.stringify(selector)}`);}- const fallbackSide = role === "source" ? normalAttack.attackerSide : normalAttack.defenderSide;+ const fallbackSide = role === "source" ? normalAttack.dealerSide : normalAttack.takerSide;return units.map((unit) => ({ side: fallbackSide, unit }));}@@ -1125,8 +1181,8 @@roundStartTroops: DamageJob["roundStartTroops"],roundTargetDamage: Record<SideId, Record<UnitType, number>>): boolean {- const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);- return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);+ return available <= 0 || roundTargetDamage[job.takerSide][job.takerUnit] >= available;}function capJobToRemainingTarget(@@ -1136,11 +1192,11 @@roundTargetDamage: Record<SideId, Record<UnitType, number>>,recorder: BattleRecorder): void {- const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);- const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];+ const available = Math.max(0, roundStartTroops[job.takerSide][job.takerUnit] ?? 0);+ const alreadyDamaged = roundTargetDamage[job.takerSide][job.takerUnit];const remaining = Math.max(0, available - alreadyDamaged);result.kills = Math.min(result.kills, remaining);- roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;+ roundTargetDamage[job.takerSide][job.takerUnit] += result.kills;recorder.recordFinalKills(result);}@@ -1151,7 +1207,7 @@commitRoundCounters(cancelled, results, runtime);const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };for (const { job, result } of results) {- losses[job.defenderSide][job.defenderUnit] += result.kills;+ losses[job.takerSide][job.takerUnit] += result.kills;}for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {@@ -1162,12 +1218,12 @@function commitRoundCounters(cancelled: CancelledAttack[], results: DamageJobResult[], runtime: Runtime): void {for (const entry of cancelled) {- runtime.counters.attacks[entry.intent.attackerSide][entry.intent.attackerUnit] += 1;- runtime.counters.received[entry.intent.defenderSide][entry.intent.defenderUnit] += 1;+ runtime.counters.attacks[entry.intent.dealerSide][entry.intent.dealerUnit] += 1;+ runtime.counters.received[entry.intent.takerSide][entry.intent.takerUnit] += 1;}for (const { job, result } of results) {- runtime.counters.attacks[job.attackerSide][job.attackerUnit] += 1;- runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;+ runtime.counters.attacks[job.dealerSide][job.dealerUnit] += 1;+ runtime.counters.received[job.takerSide][job.takerUnit] += 1;}}@@ -1228,7 +1284,7 @@function bearScore(attacks: AttackOutcome[]): number {return attacks- .filter((attack) => attack.attackerSide === "attacker" && attack.defenderSide === "defender")+ .filter((attack) => attack.dealerSide === "attacker" && attack.takerSide === "defender").reduce((sum, attack) => sum + attack.kills, 0);}diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts--- a/simulator/src/staticDamageProfile.ts+++ b/simulator/src/staticDamageProfile.ts@@ -1,260 +1,157 @@-import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";+import type { ActiveEffect, DamageBucketTrace, ResolvedFighter, SideId, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";import {BUCKET_DEFINITIONS,- STATIC_BUCKETS,- type BucketPlacement,+ isPassiveBucket,+ passiveBucketRole,type BucketRole,- type BucketValueType,- type StaticDamageBucket,- type StaticPassiveBucket,- type StaticPlayerBucket,- type StaticRawBucket+ type PassiveBucket,+ type StaticPlayerBucket} from "./damageBuckets";-import { sourceLabel } from "./effects";--export interface StaticDamageProfileTerm {- raw?: number;- totalPct?: number;- contributors: DamageBucketTrace["contributors"];-}--export interface StaticDamageProfileEntry {- factor: number;- playerFactorsValid: boolean;- buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;-}--export interface StaticDamageProfile {- offense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;- defense: Record<SideId, Record<UnitType, StaticDamageProfileEntry>>;+import {+ applyStaticDamageBucketValue,+ createStaticDamageBucketFactors,+ evaluateDamageExpression,+ staticDamageBucketSet,+ type StaticDamageBucketMatrix,+ type StaticDamageBucketFactors,+ type StaticDamageProfile,+ type StaticDamageProfileEntry+} from "./damage";++export type { StaticDamageBucketMatrix, StaticDamageProfile } from "./damage";++/** A player stat bonus produced a non-positive factor while building the numeric profile. */+export class DamageAggregationError extends Error {+ readonly groupId: string;+ readonly netPct: number;+ readonly factor: number;+ readonly contributors: DamageBucketTrace["contributors"];++ constructor(args: { groupId: string; netPct: number; factor: number; contributors: DamageBucketTrace["contributors"] }) {+ super(`Non-positive damage aggregation factor for ${args.groupId}: factor=${args.factor} netPct=${args.netPct}`);+ this.name = "DamageAggregationError";+ this.groupId = args.groupId;+ this.netPct = args.netPct;+ this.factor = args.factor;+ this.contributors = args.contributors;+ }}-interface PassiveCandidate {+/**+ * One passive effect selected to contribute to a static bucket for a concrete side and unit.+ * Selection (including same-effect max stacking) lives here so the numeric profile and any+ * recorder-side description of it can never disagree.+ */+export interface PassiveContribution {+ role: BucketRole;+ side: SideId;+ unit: UnitType;+ bucket: PassiveBucket;effect: ActiveEffect;- bucket: StaticPassiveBucket;valuePct: number;}-interface PassiveCandidateGroup {- selected: PassiveCandidate;- candidates: PassiveCandidate[];- entry: StaticDamageProfileEntry;-}--export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {- const profile: StaticDamageProfile = {- offense: {- attacker: buildSideEntries(fighters.attacker, "offense"),- defender: buildSideEntries(fighters.defender, "offense")- },- defense: {- attacker: buildSideEntries(fighters.attacker, "defense"),- defender: buildSideEntries(fighters.defender, "defense")- }+export function buildStaticDamageBucketFactors(+ fighters: Record<SideId, ResolvedFighter>,+ activeEffects: ActiveEffect[]+): StaticDamageBucketMatrix {+ const factors: Record<SideId, Record<UnitType, StaticDamageBucketFactors>> = {+ attacker: buildSideFactors(fighters.attacker),+ defender: buildSideFactors(fighters.defender)};-- applyStaticPassives(profile, activeEffects);- recomputeFactors(profile);- return profile;-}--export function assertStaticPassiveEffectDefinition(- trigger: SkillFile["skills"][string]["trigger"],- effect: EffectIntentDefinition,- file: string,- skillId: string,- effectId: string-): void {- if (!isPassiveBucket(effect.type)) return;-- const path = `${file}:${skillId}.${effectId}`;- if (trigger.type !== "battle_start") {- throw new Error(`passive effect ${effect.type} must use battle_start trigger at ${path}`);- }- if (trigger.probability !== undefined) {- throw new Error(`passive effect ${effect.type} cannot define trigger probability at ${path}; passive effects must be deterministic`);+ for (const contribution of selectPassiveContributions(activeEffects)) {+ applyStaticDamageBucketValue(+ factors[contribution.side][contribution.unit],+ contribution.bucket,+ contribution.valuePct+ );}- if (effect.value_evolution !== undefined) {- throw new Error(`passive effect ${effect.type} cannot define value_evolution at ${path}; passive effects must be static`);- }-- const duration = effect.duration;- if (duration === undefined) return;- if (duration.turns !== undefined || duration.attacks !== undefined) {- throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);- }-}--export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {- return BUCKET_DEFINITIONS[bucket as StaticDamageBucket]?.phase === "static";+ return factors;}-export function isPassiveBucket(bucket: string): bucket is StaticPassiveBucket {- const definition = BUCKET_DEFINITIONS[bucket as StaticPassiveBucket];- return definition?.phase === "static" && definition.family === "passive";-}--export function staticPassiveBucketRole(bucket: string): BucketRole | undefined {- return isPassiveBucket(bucket) ? BUCKET_DEFINITIONS[bucket].role : undefined;-}--function buildSideEntries(fighter: ResolvedFighter, role: "offense" | "defense"): Record<UnitType, StaticDamageProfileEntry> {- return Object.fromEntries(UNIT_TYPES.map((unit) => [unit, buildEntry(fighter, unit, role)])) as Record<UnitType, StaticDamageProfileEntry>;+export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {+ const factors = buildStaticDamageBucketFactors(fighters, activeEffects);+ return {+ attacker: buildProfileSide(factors.attacker),+ defender: buildProfileSide(factors.defender)+ };}-function buildEntry(fighter: ResolvedFighter, unit: UnitType, role: "offense" | "defense"): StaticDamageProfileEntry {- const stats = fighter.troopDetails[unit]?.stats ?? fallbackStats();- const bonuses = fighter.statBonuses[unit] ?? emptyStats();- const buckets: StaticDamageProfileEntry["buckets"] = {};- if (role === "offense") {- setRaw(buckets, "troops.baseAttack", stats.attack);- setRaw(buckets, "troops.baseLethality", stats.lethality);- setPct(buckets, "player.attack", bonuses.attack, [{ effectId: "input:attack", source: "input_stats", valuePct: bonuses.attack, bucket: "player.attack" }]);- setPct(buckets, "player.lethality", bonuses.lethality, [- { effectId: "input:lethality", source: "input_stats", valuePct: bonuses.lethality, bucket: "player.lethality" }- ]);- } else {- setRaw(buckets, "troops.baseHealth", stats.health);- setRaw(buckets, "troops.baseDefense", stats.defense);- setPct(buckets, "player.health", bonuses.health, [{ effectId: "input:health", source: "input_stats", valuePct: bonuses.health, bucket: "player.health" }]);- setPct(buckets, "player.defense", bonuses.defense, [- { effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }- ]);+function buildProfileSide(factors: Record<UnitType, StaticDamageBucketFactors>): Record<UnitType, StaticDamageProfileEntry> {+ return Object.fromEntries(+ UNIT_TYPES.map((unit) => [unit, {+ dealerFactor: evaluateDamageExpression(staticDamageBucketSet(factors[unit], "dealer")),+ takerFactor: evaluateDamageExpression(staticDamageBucketSet(factors[unit], "taker"))+ }])+ ) as Record<UnitType, StaticDamageProfileEntry>;+}++function applyPlayerBucket(factors: StaticDamageBucketFactors, bucket: StaticPlayerBucket, totalPct: number): void {+ const factor = applyStaticDamageBucketValue(factors, bucket, totalPct);+ if (factor <= 0) {+ const stat = bucket.slice("player.".length);+ throw new DamageAggregationError({+ groupId: `player.${BUCKET_DEFINITIONS[bucket].role}.${stat}`,+ netPct: totalPct,+ factor,+ contributors: [{ effectId: `input:${stat}`, source: "input_stats", valuePct: totalPct, bucket }]+ });}- return { factor: 1, playerFactorsValid: true, buckets };}-function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {- const groups = new Map<string, PassiveCandidateGroup>();+export function selectPassiveContributions(activeEffects: ActiveEffect[]): PassiveContribution[] {+ const contributions: PassiveContribution[] = [];+ const maxGroups = new Map<string, PassiveContribution>();for (const effect of activeEffects) {const bucket = effect.intent.type;if (!isPassiveBucket(bucket)) continue;- const role = staticPassiveBucketRole(bucket)!;- const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];+ const role = passiveBucketRole(bucket)!;+ const side = effect.appliesTo.side;for (const unit of UNIT_TYPES) {if (!unitMaskHas(effect.appliesTo.units, unit)) continue;- const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };+ const contribution: PassiveContribution = { role, side, unit, bucket, effect, valuePct: effect.getCurrentValuePct(1) };if (effect.sameEffectStacking === "max" && effect.stackingKey) {- const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;- const group = groups.get(key);- if (group) {- group.candidates.push(candidate);- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;- } else {- groups.set(key, { selected: candidate, candidates: [candidate], entry: targetEntries[unit] });- }+ const key = `${role}:${side}:${unit}:${bucket}:${effect.stackingKey}`;+ const selected = maxGroups.get(key);+ if (!selected || contribution.valuePct > selected.valuePct) maxGroups.set(key, contribution);} else {- addPassiveCandidate(targetEntries[unit], candidate);+ contributions.push(contribution);}}}- for (const group of groups.values()) {- addPassiveCandidate(group.entry, group.selected);- }-}--function addPassiveCandidate(entry: StaticDamageProfileEntry, candidate: PassiveCandidate): void {- addPct(entry.buckets, candidate.bucket, candidate.valuePct, {- effectId: candidate.effect.source.effectId ?? candidate.effect.id,- source: sourceLabel(candidate.effect),- sourceSide: candidate.effect.ownerSide,- valuePct: candidate.valuePct,- bucket: candidate.bucket,- stackingKey: candidate.effect.stackingKey,- sameEffectStacking: candidate.effect.sameEffectStacking- });-}--function recomputeFactors(profile: StaticDamageProfile): void {- for (const side of ["attacker", "defender"] as SideId[]) {- for (const unit of UNIT_TYPES) {- const offense = profile.offense[side][unit];- const defense = profile.defense[side][unit];- offense.factor = offenseFactor(offense);- defense.factor = defenseFactor(defense);- offense.playerFactorsValid = playerPctFactorValid(offense, "player.attack") && playerPctFactorValid(offense, "player.lethality");- defense.playerFactorsValid = playerPctFactorValid(defense, "player.health") && playerPctFactorValid(defense, "player.defense");- }- }-}--function playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;-}--interface StaticFactorTerm {- bucket: StaticDamageBucket;- valueType: BucketValueType;- placement: BucketPlacement;-}--// The closed-pool aggregation is driven entirely by static bucket metadata (role/valueType/-// placement); there is no second hand-written damage equation. Array order fixes the-// numerator/denominator floating-point association.-const STATIC_FACTOR_TERMS_BY_ROLE: Record<BucketRole, StaticFactorTerm[]> = { attacker: [], defender: [] };-for (const definition of STATIC_BUCKETS) {- STATIC_FACTOR_TERMS_BY_ROLE[definition.role].push({- bucket: definition.path,- valueType: definition.valueType,- placement: definition.placement- });-}--function roleFactor(entry: StaticDamageProfileEntry, terms: StaticFactorTerm[]): number {- let numerator = 1;- let denominator = 1;- for (const term of terms) {- const value = term.valueType === "raw" ? raw(entry, term.bucket) : pct(entry, term.bucket);- if (term.placement === "numerator") numerator *= value;- else denominator *= value;- }- return numerator / denominator;-}--function offenseFactor(entry: StaticDamageProfileEntry): number {- return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.attacker);-}--function defenseFactor(entry: StaticDamageProfileEntry): number {- return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.defender);-}--function raw(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {- return Math.max(0, entry.buckets[bucket]?.raw ?? 0);-}--function pct(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {- return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;+ contributions.push(...maxGroups.values());+ return contributions;}-function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {- buckets[bucket] = { raw, contributors: [] };+function buildSideFactors(fighter: ResolvedFighter): Record<UnitType, StaticDamageBucketFactors> {+ return Object.fromEntries(+ UNIT_TYPES.map((unit) => {+ const factors = createStaticDamageBucketFactors();+ const stats = unitBaseStats(fighter, unit);+ const bonuses = unitPlayerBonuses(fighter, unit);+ applyStaticDamageBucketValue(factors, "troops.baseAttack", stats.attack);+ applyStaticDamageBucketValue(factors, "troops.baseLethality", stats.lethality);+ applyStaticDamageBucketValue(factors, "troops.baseHealth", stats.health);+ applyStaticDamageBucketValue(factors, "troops.baseDefense", stats.defense);+ applyPlayerBucket(factors, "player.attack", bonuses.attack);+ applyPlayerBucket(factors, "player.lethality", bonuses.lethality);+ applyPlayerBucket(factors, "player.health", bonuses.health);+ applyPlayerBucket(factors, "player.defense", bonuses.defense);+ return [unit, factors];+ })+ ) as Record<UnitType, StaticDamageBucketFactors>;}-function setPct(- buckets: StaticDamageProfileEntry["buckets"],- bucket: StaticPlayerBucket,- valuePct: number,- contributors: DamageBucketTrace["contributors"] = []-): void {- buckets[bucket] = { totalPct: valuePct, contributors };+/** Base stats a static entry reads for a unit; units with no troops resolve to neutral 1s. */+export function unitBaseStats(fighter: ResolvedFighter, unit: UnitType): StatBlock {+ return fighter.troopDetails[unit]?.stats ?? FALLBACK_STATS;}-function addPct(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticPassiveBucket, valuePct: number, contributor: DamageBucketTrace["contributors"][number]): void {- const existing = buckets[bucket];- if (existing) {- existing.totalPct = (existing.totalPct ?? 0) + valuePct;- existing.contributors.push(contributor);- } else {- buckets[bucket] = { totalPct: valuePct, contributors: [contributor] };- }+/** Player stat bonuses a static entry reads for a unit; missing units contribute nothing. */+export function unitPlayerBonuses(fighter: ResolvedFighter, unit: UnitType): StatBlock {+ return fighter.statBonuses[unit] ?? EMPTY_STATS;}-function fallbackStats(): StatBlock {- return { attack: 1, defense: 1, lethality: 1, health: 1 };-}--function emptyStats(): StatBlock {- return { attack: 0, defense: 0, lethality: 0, health: 0 };-}+const FALLBACK_STATS: StatBlock = { attack: 1, defense: 1, lethality: 1, health: 1 };+const EMPTY_STATS: StatBlock = { attack: 0, defense: 0, lethality: 0, health: 0 };diff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts--- a/simulator/src/testcases.test.ts+++ b/simulator/src/testcases.test.ts@@ -140,7 +140,7 @@assert.equal(report.counts.executed, 1);assert.equal(report.errors[0]?.testcase_id, "bad_aggregation");assert.equal(report.details[0]?.errorDetails?.type, "DamageAggregationError");- assert.equal(report.details[0]?.errorDetails?.groupId, "player.defender.health");+ assert.equal(report.details[0]?.errorDetails?.groupId, "player.taker.health");assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");});diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts--- a/simulator/src/testcases.ts+++ b/simulator/src/testcases.ts@@ -10,7 +10,7 @@} from "./calibration";import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";import { prepareBattle, runPrepared } from "./simulator";-import { DamageAggregationError } from "./damage";+import { DamageAggregationError } from "./staticDamageProfile";import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "./types";const DEFAULT_STOCHASTIC_REPEAT = 100;@@ -775,8 +775,6 @@return {type: error.name,groupId: error.groupId,- round: error.round,- jobId: error.jobId,netPct: error.netPct,factor: error.factor,contributors: error.contributorsdiff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -312,10 +312,10 @@id: string;round: number;source: "normal";- attackerSide: SideId;- attackerUnit: UnitType;- defenderSide: SideId;- defenderUnit: UnitType;+ dealerSide: SideId;+ dealerUnit: UnitType;+ takerSide: SideId;+ takerUnit: UnitType;orderIndex: number;previousAttackCount: number;projectedAttackCount: number;@@ -329,10 +329,10 @@kind: DamageKind;sourceIntentId: string;roundStartTroops: Record<SideId, Record<UnitType, number>>;- attackerSide: SideId;- attackerUnit: UnitType;- defenderSide: SideId;- defenderUnit: UnitType;+ dealerSide: SideId;+ dealerUnit: UnitType;+ takerSide: SideId;+ takerUnit: UnitType;sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;@@ -417,10 +417,10 @@kind: DamageKind;sourceEffectId?: string;sourceSkillReportKey?: string;- attackerSide: SideId;- attackerUnit: UnitType;- defenderSide: SideId;- defenderUnit: UnitType;+ dealerSide: SideId;+ dealerUnit: UnitType;+ takerSide: SideId;+ takerUnit: UnitType;kills: number;counterDeltas: CounterDelta[];appliedEffects: AppliedEffect[];