← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.19%
Avg Error B0.20%
Δ Avg Error+0.00%
Improved0
Regressed0
Added0
Retired0
Testcase Delta
201 / 201
Code / Config Changes
Commits (7f8f1286 → 79ed9788)
79ed9788Remove legacy baseline and report parser fixpiddlyminx04/07/2026, 15:44:28
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Ahmose.json===================================================================--- simulator/config/hero_definitions/Ahmose.json prev run+++ simulator/config/hero_definitions/Ahmose.json this run@@ -18,10 +18,11 @@"infantry"]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}},"ViperFormation/2": {"type": "active.hero.damageTaken.down",@@ -38,11 +39,12 @@"marksman"]},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"},"ViperFormation/3": {@@ -59,11 +61,12 @@"infantry"]},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"}}@@ -112,10 +115,11 @@"infantry"]},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}},"BladeOfLight/2": {"type": "active.hero.damageTaken.up",@@ -129,10 +133,11 @@"units": {"applies_to": "target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "add"}}Index: simulator/config/hero_definitions/Alonso.json===================================================================--- simulator/config/hero_definitions/Alonso.json prev run+++ simulator/config/hero_definitions/Alonso.json this run@@ -30,10 +30,11 @@"applies_to": "trigger","applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}},@@ -63,11 +64,12 @@"applies_to": "target","applies_vs": "any"},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"}}@@ -98,10 +100,11 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [{Index: simulator/config/hero_definitions/Bahiti.json===================================================================--- simulator/config/hero_definitions/Bahiti.json prev run+++ simulator/config/hero_definitions/Bahiti.json this run@@ -46,10 +46,11 @@"units": {"applies_to": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}}Index: simulator/config/hero_definitions/Bradley.json===================================================================--- simulator/config/hero_definitions/Bradley.json prev run+++ simulator/config/hero_definitions/Bradley.json this run@@ -75,10 +75,11 @@24,30],"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"}}Index: simulator/config/hero_definitions/Gordon.json===================================================================--- simulator/config/hero_definitions/Gordon.json prev run+++ simulator/config/hero_definitions/Gordon.json this run@@ -25,10 +25,11 @@"lancer"]},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}},"VenomInfusion/2": {"type": "active.hero.damage.down",@@ -42,10 +43,11 @@"units": {"applies_to": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}},@@ -70,10 +72,11 @@"lancer"]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"},"ChemicalTerror/2": {@@ -89,10 +92,11 @@"applies_to": "enemy.any","applies_vs": "self.any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}}@@ -116,10 +120,11 @@"units": {"applies_to": "enemy.infantry"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"},"ToxicRelease/2": {@@ -134,10 +139,11 @@"units": {"applies_to": "enemy.marksman"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"}}@@ -150,9 +156,9 @@"requirements": [{"level": 1,"type": "engagement_type",- "value": "garrison"+ "value": "rally"}],"effects": {"BioAssault/1": {Index: simulator/config/hero_definitions/Greg.json===================================================================--- simulator/config/hero_definitions/Greg.json prev run+++ simulator/config/hero_definitions/Greg.json this run@@ -30,10 +30,11 @@"applies_to": "all","applies_vs": "any"},"duration": {- "type": "turn",- "value": 3+ "turns": {+ "count": 3+ }}}}},@@ -64,10 +65,11 @@"applies_to": "trigger.target","applies_vs": "self.any"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}}}},Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -23,10 +23,11 @@"applies_to": "trigger.target","applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}},@@ -50,18 +51,19 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source","target": "effect.applies_vs"}]- },+ },"AirDominance/2": {"type": "active.hero.damageTaken.up","value": [5,@@ -73,11 +75,12 @@"units": {"applies_to": "target"},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "attacks": {+ "count": 1,+ "delay": 1+ }}}}},@@ -103,11 +106,12 @@"marksman"]},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "attacks": {+ "count": 1,+ "delay": 1+ }},"trigger_damage_jobs": [{"source": "use.source",Index: simulator/config/hero_definitions/Hector.json===================================================================--- simulator/config/hero_definitions/Hector.json prev run+++ simulator/config/hero_definitions/Hector.json this run@@ -25,10 +25,11 @@40,50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}}@@ -54,10 +55,11 @@],"applies_vs": "any"},"duration": {- "type": "attack",- "value": 10+ "attacks": {+ "count": 10+ }},"value_evolution": {"type": "pct_decay","step": "attack",@@ -79,10 +81,11 @@],"applies_vs": "any"},"duration": {- "type": "attack",- "value": 10+ "attacks": {+ "count": 10+ }},"value_evolution": {"type": "pct_decay","step": "attack",@@ -117,10 +120,11 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",Index: simulator/config/hero_definitions/Jeronimo.json===================================================================--- simulator/config/hero_definitions/Jeronimo.json prev run+++ simulator/config/hero_definitions/Jeronimo.json this run@@ -59,10 +59,11 @@"applies_to": "all","applies_vs": "any"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}}}},Index: simulator/config/hero_definitions/Lynn.json===================================================================--- simulator/config/hero_definitions/Lynn.json prev run+++ simulator/config/hero_definitions/Lynn.json this run@@ -25,10 +25,11 @@40,50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}}Index: simulator/config/hero_definitions/Mia.json===================================================================--- simulator/config/hero_definitions/Mia.json prev run+++ simulator/config/hero_definitions/Mia.json this run@@ -28,10 +28,11 @@"units": {"applies_to": "target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}}@@ -62,10 +63,11 @@"applies_to": "trigger.source","applies_vs": "any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [{@@ -98,10 +100,11 @@40,50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}}Index: simulator/config/hero_definitions/Molly.json===================================================================--- simulator/config/hero_definitions/Molly.json prev run+++ simulator/config/hero_definitions/Molly.json this run@@ -28,10 +28,11 @@"units": {"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}},@@ -61,10 +62,11 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",Index: simulator/config/hero_definitions/Natalia.json===================================================================--- simulator/config/hero_definitions/Natalia.json prev run+++ simulator/config/hero_definitions/Natalia.json this run@@ -28,10 +28,11 @@"units": {"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}},Index: simulator/config/hero_definitions/Norah.json===================================================================--- simulator/config/hero_definitions/Norah.json prev run+++ simulator/config/hero_definitions/Norah.json this run@@ -73,10 +73,11 @@],"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [{@@ -108,10 +109,11 @@"applies_to": "all","applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}},"Momentum/2": {"type": "active.hero.damageTaken.down",@@ -122,11 +124,12 @@20,25],"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }}}}},Index: simulator/config/hero_definitions/Philly.json===================================================================--- simulator/config/hero_definitions/Philly.json prev run+++ simulator/config/hero_definitions/Philly.json this run@@ -57,10 +57,11 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",@@ -95,10 +96,11 @@"units": {"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}},Index: simulator/config/hero_definitions/Reina.json===================================================================--- simulator/config/hero_definitions/Reina.json prev run+++ simulator/config/hero_definitions/Reina.json this run@@ -47,10 +47,11 @@"units": {"applies_to": "trigger"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}},@@ -83,10 +84,11 @@],"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [{Index: simulator/config/hero_definitions/Renee.json===================================================================--- simulator/config/hero_definitions/Renee.json prev run+++ simulator/config/hero_definitions/Renee.json this run@@ -24,11 +24,15 @@"applies_to": "target","applies_vs": "lancer"},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ },+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max"}}@@ -54,11 +58,12 @@"applies_to": "target","applies_vs": "lancer"},"duration": {- "type": "turn",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ }},"same_effect_stacking": "max"}}@@ -84,11 +89,12 @@"applies_to": "target","applies_vs": "any"},"duration": {- "type": "turn",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ }},"same_effect_stacking": "max"}}Index: simulator/config/hero_definitions/Wayne.json===================================================================--- simulator/config/hero_definitions/Wayne.json prev run+++ simulator/config/hero_definitions/Wayne.json this run@@ -24,10 +24,11 @@"applies_to": "trigger.source","applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [{@@ -59,10 +60,11 @@"applies_to": "trigger.source","applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",@@ -85,10 +87,11 @@"applies_to": "trigger.source","applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",@@ -126,10 +129,11 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",Index: simulator/config/troop_skills.json===================================================================--- simulator/config/troop_skills.json prev run+++ simulator/config/troop_skills.json this run@@ -96,10 +96,11 @@"applies_to": "trigger.target","applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}},@@ -205,10 +206,11 @@"marksman"]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}},@@ -248,10 +250,11 @@],"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",@@ -289,10 +292,11 @@"applies_to": "trigger.target","applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}},@@ -353,10 +357,11 @@"applies_to": "trigger.source","applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",@@ -402,10 +407,11 @@],"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",@@ -469,10 +475,11 @@],"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{"source": "use.source",Index: simulator/src/classifierDamage.test.ts===================================================================--- simulator/src/classifierDamage.test.ts prev run+++ simulator/src/classifierDamage.test.ts this run@@ -100,9 +100,11 @@for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);removeStaticProfileBucketEffects(effectIndex);- return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };}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");@@ -411,22 +413,22 @@assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);});-test("attack-duration bucket effects are consumed by the applicable attack job", () => {+test("attack-duration bucket effects are charged by the applicable attack job", () => {const oneAttackEffect = {...effect("active.hero.attack.up", "attacker", 100),id: "attack-up-active",duration: { type: "attack" as const, value: 1 }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });- assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);});-test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {const turnEffect = {...effect("active.hero.defense.down", "defender", 30),id: "bad-luck-like",source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },@@ -434,12 +436,13 @@};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });- assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);- assert.ok(outcome.appliedEffectIds?.includes("BadLuckStreak/1"));+ assert.ok(outcome.usedEffectIds.includes("bad-luck-like"));assert.deepEqual(outcome.appliedEffects, [{+ kind: "modifier",+ activeEffectId: "bad-luck-like",effectId: "BadLuckStreak/1",bucket: "active.hero.defense.down",valuePct: 30,source: "Example/Skill/BadLuckStreak/1",@@ -448,9 +451,9 @@}]);});-test("attack-duration effects are only consumed when they participate in the calculation", () => {+test("effects are only charged when they participate in the calculation", () => {const defenderOutgoingBuff = {...effect("active.hero.lethality.up", "defender", 100),id: "defender-outgoing-buff",duration: { type: "attack" as const, value: 1 }@@ -458,9 +461,9 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);- assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);});test("pct attack value evolution uses the effect use count for the current bucket value", () => {const decayedAttackUp = {@@ -480,9 +483,9 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [decayedAttackUp], { trace: true });assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);- assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));});test("pct turn value evolution starts decaying after the first active turn", () => {const turnDecayAttackUp = {@@ -505,9 +508,9 @@assert.equal(firstTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);});-test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {const weaker = {...effect("active.hero.lethality.up", "attacker", 50),id: "max-weaker",duration: { type: "attack" as const, value: 3 },@@ -532,9 +535,9 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [weaker, strongerButDecayed], { trace: true });assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);- assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));});test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {const active = activateEffect(Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -70,8 +70,19 @@assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);});+test("loadSimulatorConfig rejects legacy duration shape", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "target" },+ duration: { type: "attack", value: 1 } as never+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /duration key type.*not supported/i);+});+test("simulator config source does not reference legacy effect metadata names", () => {const legacyEffectMetadataKey = ["effect", "op"].join("_");const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");@@ -165,9 +176,9 @@const durationRoot = writeConfigWithTroopEffect({type: "passive.attack.up",value: 10,- duration: { type: "round", value: 1 }+ duration: { turns: { count: 1 } }},{ type: "battle_start" });const evolvingRoot = writeConfigWithTroopEffect(Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -172,8 +172,9 @@collectAmbiguousTurnTriggerSelectorDiagnostics(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId, diagnostics);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);if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);if (!KNOWN_EFFECT_TYPES.has(type)) {diagnostics.unsupportedEffects.push({@@ -262,8 +263,47 @@}}}+function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {+ if (effect.duration === undefined) return;+ 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") {+ 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) {+ const value = duration[key];+ if (value === undefined) continue;+ validateNativeEffectDurationAxis(value, `${path}.${key}`);+ }+}++function validateNativeEffectDurationAxis(value: unknown, path: string): void {+ if (!value || typeof value !== "object" || Array.isArray(value)) {+ throw new Error(`native effect duration axis must be an object at ${path}`);+ }+ const axis = value as Record<string, unknown>;+ for (const key of Object.keys(axis)) {+ if (key !== "count" && key !== "delay") {+ throw new Error(`native effect duration axis key ${key} is not supported at ${path}; use count and/or delay`);+ }+ }+ if (axis.count === undefined && axis.delay === undefined) {+ throw new Error(`native effect duration axis must define count or delay at ${path}`);+ }+ if (axis.count !== undefined) validateIntegerRange(axis.count, 1, `${path}.count`);+ if (axis.delay !== undefined) validateIntegerRange(axis.delay, 0, `${path}.delay`);+}++function validateIntegerRange(value: unknown, min: number, path: string): void {+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {+ throw new Error(`native effect duration ${path} must be an integer >= ${min}`);+ }+}+function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {const path = `${file}:${skillId}.${effectId}`;if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {throw new Error(`extra_skill_attack requires non-empty trigger_damage_jobs at ${path}`);Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -11,9 +11,9 @@} from "./types";import { UNIT_TYPES } from "./types";import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive } from "./effects";+import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,type StaticDamageBucket,@@ -55,22 +55,19 @@}export type DamageScratch = NumericDamageBuckets;-// Lean result of a single damage job: the correctness-relevant outputs (kills, consumed-// effect bookkeeping) plus, only when tracing, the recording detail. The heavy per-attack+// Lean result of a single damage job: kills plus, only when tracing, the recording detail.+// 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.export interface DamageResult {kills: number;- consumedEffectIds: string[];- appliedEffectIds?: string[];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 EMPTY_CONSUMED_EFFECT_IDS: string[] = [];const TROOPS_COUNT_TERM = factorTerm("troops.count");const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");@@ -108,9 +105,9 @@export function calculateDamageJob(job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean }+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<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.@@ -127,11 +124,11 @@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);- const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];- const consumedEffectIds = new Set<string>();+ 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;@@ -157,9 +154,9 @@rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });}}- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];@@ -183,14 +180,10 @@finalKills: kills}: undefined;- const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];-return {kills,- consumedEffectIds: returnedConsumedEffectIds,- appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,appliedEffects: traceEnabled ? appliedEffects : undefined,trace};}@@ -200,9 +193,9 @@buckets: NumericDamageBuckets,detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;for (const candidate of candidates) {if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {@@ -215,14 +208,14 @@} else {maxGroups.set(key, { selected: candidate, candidates: [candidate] });}} else {- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);}}// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the@@ -245,8 +238,10 @@selected.effect.sameEffectStacking);if (appliedValuePct !== 0 && detail === "full") {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {+ kind: "modifier",+ activeEffectId: selected.effect.id,effectId: selected.effect.source.effectId ?? selected.effect.id,bucket: selected.bucket,valuePct: appliedValuePct,source: sourceLabel(selected.effect),@@ -266,13 +261,13 @@buckets: NumericDamageBuckets,detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);if (appliedValuePct !== 0) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ usedEffects.add(candidate.effect);} else if (detail === "full") {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });}}@@ -283,14 +278,15 @@buckets: NumericDamageBuckets,detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);if (appliedValuePct !== 0) {+ // The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.for (const candidate of candidates) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ usedEffects.add(candidate.effect);if (detail === "full" && candidate !== selected) {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });}}@@ -354,8 +350,10 @@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,@@ -559,7 +557,4 @@function pctFromFactor(factor: number): number {return Number(((factor - 1) * 100).toFixed(12));}-function sourceLabel(effect: ActiveEffect): string {- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");-}Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -13,8 +13,9 @@import { ALL_UNIT_MASK, unitMask } from "./types";import { normalizeUnitType } from "./normalize";export type Rng = () => number;+type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];interface CompiledTriggerSelectors {source: ParsedTriggerSelector;target: ParsedTriggerSelector;@@ -126,15 +127,21 @@function skillStackingSourceKey(skill: ResolvedSkill): string {return skill.heroName ?? skill.troopType ?? "global";}+export function sourceLabel(effect: ActiveEffect): string {+ 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;- if (effect.duration.type === "battle") return true;- if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);- return effect.uses < Math.max(1, effect.duration.value);+ return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));}+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;@@ -228,15 +235,46 @@}function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {if (!duration) return { type: "battle", value: 0 };- const rawType = duration.type === "turn" ? "round" : duration.type;- if (rawType === "round" || rawType === "attack" || rawType === "battle") {- return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 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;+}++function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {+ return {+ type,+ ...(value.count === undefined ? {} : { count: Number(value.count) }),+ ...(value.delay === undefined ? {} : { delay: Number(value.delay) })+ };+}+function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {return value === "max" ? "max" : "add";}Index: simulator/src/recorder.ts===================================================================--- simulator/src/recorder.ts prev run+++ simulator/src/recorder.ts this run@@ -1,5 +1,6 @@import type {+ AppliedEffect,AttackIntent,AttackOutcome,BattleTrace,DamageJob,@@ -10,9 +11,9 @@import type { DamageResult } from "./damage";/*** Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop- * does all simulation-affecting work itself (kills, counters, effect consumption); the recorder+ * 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.@@ -21,16 +22,17 @@*/export interface BattleRecorder {/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */readonly capturesTrace: boolean;- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void;- recordDamageJob(job: DamageJob, result: DamageResult): void;+ 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;readonly attacks: AttackOutcome[];readonly trace: BattleTrace | undefined;}const NO_ATTACKS: AttackOutcome[] = [];+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];export const NULL_RECORDER: BattleRecorder = {capturesTrace: false,recordCancelled() {},@@ -61,9 +63,9 @@this.trace = trace;this.capturesTrace = trace !== undefined;}- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {this.attacks.push({jobId: `${intent.id}:cancelled`,kind: "normal",attackerSide: intent.attackerSide,@@ -74,17 +76,15 @@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" }],- appliedEffectIds: [],- appliedEffects: [],- consumedEffectIds,+ appliedEffects,cancelledBy: effectId,cancelReason: reason});}- recordDamageJob(job: DamageJob, result: DamageResult): void {+ 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);if (report) report.skillKills += result.kills;}@@ -102,15 +102,19 @@counterDeltas: [{ side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },{ side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }],- appliedEffectIds: result.appliedEffectIds ?? [],- appliedEffects: result.appliedEffects ?? [],- consumedEffectIds: result.consumedEffectIds,+ appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),trace: result.trace});}recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void {this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });}}++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];+}Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -6,9 +6,9 @@import { loadSimulatorConfig } from "./config";import { createSeededRng, chancePasses } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./resolve";import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";+import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";test("runPrepared reuses the compiled input seed when no override is supplied", () => {const config = loadSimulatorConfig();const stats = {@@ -256,9 +256,9 @@buff: {type: "active.hero.lethality.up",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}}@@ -295,9 +295,9 @@guard: {type: "active.hero.defense.up",value: 100,units: { applies_to: "target", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -310,8 +310,62 @@assert.equal(report?.skillActivations, 2);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(+ {+ maxRounds: 4,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DreamLancer: {+ name: "DreamLancer",+ troop_type: "lancer",+ skills: {+ EvenTurnAmbusher: {+ trigger: { type: "turn", every: 2 },+ effects: {+ order: {+ type: "attack_order",+ value: ["marksman", "infantry", "lancer"],+ units: { applies_to: "lancer", applies_vs: "marksman" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ NightmareTrace: {+ trigger: { type: "turn", every: 2, source: "lancer" },+ effects: {+ mark: {+ type: "active.hero.defense.down",+ value: 100,+ units: { applies_to: "target", applies_vs: "lancer" },+ duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { 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 round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));+ assert.equal(round4LancerAttack?.defenderUnit, "marksman");+ assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle({@@ -415,9 +469,9 @@boost: {type: "active.hero.lethality.up",value: 10,units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },- duration: { type: "battle", value: 1 },+ duration: {},same_effect_stacking: "add"}}}@@ -471,9 +525,9 @@assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });});-test("attack-duration effects are consumed even when a normal attack is cancelled", () => {+test("cancelled attacks report the winning control as an applied effect", () => {const result = simulateBattle({maxRounds: 1,attacker: {@@ -495,35 +549,91 @@cancel: {type: "no_attack",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},debuff: {type: "active.hero.attack.up",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}}- })+ }),+ { mode: "trace" });const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);const cancelledAttack = cancelled!;- assert.equal(cancelledAttack.consumedEffectIds.length, 2);- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));+ assert.equal(cancelledAttack.appliedEffects.length, 1);+ const controlEvent = cancelledAttack.appliedEffects[0];+ assert.equal(controlEvent.kind, "control");+ assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");+ assert.ok(controlEvent.activeEffectId.includes(":cancel:"));assert.deepEqual(cancelledAttack.counterDeltas, [{ side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }]);});+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {+ // Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is+ // active. By default the cancelled attack charges the buff, so round 2 lands without it;+ // with useEffectsOnNoAttack:false the buff survives to boost round 2.+ const input = {+ maxRounds: 2,+ seed: "cancel-charge",+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: { SelfStunned: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ }+ };+ const config = minimalConfig({+ SelfStunned: {+ name: "SelfStunned",+ skills: {+ StunAndBuff: {+ trigger: { type: "battle_start" },+ effects: {+ stun: {+ type: "no_attack",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1, delay: 1 } }+ },+ buff: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ });++ const roundTwoKills = (options: SimulationOptions): number => {+ const result = simulateBattle(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;+ };++ const charged = roundTwoKills({});+ const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });+ 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({maxRounds: 4,@@ -545,9 +655,9 @@effects: {cancel: {type: "no_attack",units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -636,9 +746,9 @@value: 100,same_effect_stacking: "max",units: { applies_to: ["infantry"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}}@@ -650,11 +760,13 @@const roundTwoNormalOutcome = result.attacks.find((attack) => attack.kind === "normal" && attack.jobId.startsWith("r2:attacker:infantry"));const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));assert.notEqual(roundTwoNormalOutcome, undefined);- assert.equal(roundTwoNormalOutcome!.consumedEffectIds.length, 2);+ const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");+ assert.equal(extraAttackEvents.length, 1);+ assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);assert.notEqual(roundTwoSkillOutcome, undefined);- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);+ assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);});test("requires_effect is ignored by native simulator effect activation", () => {const result = simulateBattle(@@ -766,16 +878,16 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},hitMarksman: {type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -818,9 +930,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -860,9 +972,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -876,9 +988,9 @@shield: {type: "active.hero.defense.up",value: 50,units: { applies_to: "trigger.target", applies_vs: "trigger.source" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -918,16 +1030,16 @@type: "extra_skill_attack",value: 500,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},second: {type: "extra_skill_attack",value: 500,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1021,9 +1133,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source" } as never],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1060,9 +1172,9 @@hitAgain: {type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1097,9 +1209,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1144,9 +1256,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1160,9 +1272,9 @@cancel: {type: "no_attack",value: 100,units: { applies_to: "enemy.marksman", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1172,9 +1284,9 @@);const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);- assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);+ assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];assert.deepEqual(skillJobsByRound, [0, 1]);assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });@@ -1204,9 +1316,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}}@@ -1247,9 +1359,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}},RecursiveGuard: {@@ -1259,9 +1371,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1303,9 +1415,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}}@@ -1381,9 +1493,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1421,9 +1533,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: ["lancer"] },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1464,9 +1576,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}},SkillDamageBoost: {@@ -1475,9 +1587,9 @@boost: {type: "type.skill.damage.up",value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}}@@ -1518,9 +1630,9 @@down: {type: "active.hero.lethality.down",value: 50,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}}@@ -1561,9 +1673,9 @@down: {type: "active.hero.lethality.down",value: 50,units: { applies_to: "trigger.target", applies_vs: "any" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}}@@ -1778,9 +1890,9 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}}@@ -1793,9 +1905,9 @@effects: {pause: {type: "no_attack",units: { applies_to: "enemy.marksman", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}@@ -1937,16 +2049,16 @@value: 100,same_effect_stacking,units: { applies_to: ["infantry"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "turn", value: 2 }+ duration: { turns: { count: 2 } }}: {type,value: 100,same_effect_stacking,units: { applies_to: ["infantry"], applies_vs: "any" },- duration: { type: "turn", value: 2 }+ duration: { turns: { count: 2 } }};return minimalConfig({[heroName]: {name: heroName,Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -1,6 +1,10 @@import type {ActiveEffect,+ AppliedControlEffect,+ AppliedEffect,+ AppliedExtraAttackEffect,+ AppliedOrderEffect,AttackIntent,AttackOutcome,BearBattleResult,BattleInput,@@ -26,13 +30,15 @@activateEffect,chancePasses,createSeededRng,currentEffectValuePct,+ hasAttackDurationConstraint,isEffectActive,oppositeSide,parseTriggerSelector,sideForTriggerRelation,skillMatchesTrigger,+ sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";@@ -48,9 +54,11 @@interface Runtime {activeEffects: ActiveEffect[];effectIndex: EffectIndex;- effectById: Map<string, ActiveEffect>;+ // 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>;staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;skills: RuntimeSkills;@@ -79,9 +87,10 @@}interface ExtraSkillJobsResult {jobs: DamageJob[];- consumedEffectIds: string[];+ usedEffects: ActiveEffect[];+ appliedEffects?: AppliedExtraAttackEffect[];}interface BattleRun {fighters: Record<SideId, ResolvedFighter>;@@ -221,9 +230,9 @@removeStaticProfileBucketEffects(effectIndex);return {activeEffects,effectIndex,- effectById: new Map(activeEffects.map((e) => [e.id, e])),+ usedEffects: new Set(),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,skills: template.skills,@@ -298,8 +307,12 @@): 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+ };let rounds = 0;let score = 0;for (let round = 1; round <= maxRounds; round += 1) {@@ -308,9 +321,11 @@const roundStartTroops = snapshotTroops(fighters);expireInactive(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);- const intents = resolveAttackIntents(round, runtime, roundStartTroops);+ // Trace-only: battle_order applied events keyed by the intent they ordered.+ const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);const allJobs: DamageJob[] = []; // for recorderconst results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];@@ -323,64 +338,73 @@const controls = applicableControls(job, round, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;runtime.attackControlCounts[control.reason] += 1;- const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);- cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });- consumeEffects(runtime, consumedEffectIds);+ if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ cancelled.push({+ intent,+ effectId: control.effect.id,+ reason: control.reason,+ appliedEffects: recorder.capturesTrace+ ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))+ : NO_APPLIED_EFFECTS+ });} else {pendingNormalJobs.push(job);}}// Phase 2: calculate each normal job; any extra_skill_attack effect it// uses spawns extra DamageJobs that are calculated immediately after.- // consumeEffects runs after each job so subsequent normal jobs see the+ // chargeUsedEffects runs after each job so subsequent normal jobs see the// correct uses count on any shared extra_skill_attack effects.for (const job of pendingNormalJobs) {allJobs.push(job);const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects});- results.push({ job, result: normalResult });if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}- consumeEffects(runtime, normalResult.consumedEffectIds);+ chargeUsedEffects(runtime);- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);- if (extraSkill.consumedEffectIds.length > 0) {- normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];- consumeEffects(runtime, extraSkill.consumedEffectIds);- }+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);+ for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;+ results.push({+ job,+ result: normalResult,+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)+ });for (const extraJob of extraSkill.jobs) {allJobs.push(extraJob);const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects});results.push({ job: extraJob, result: extraResult });if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {score += extraResult.kills;}- consumeEffects(runtime, extraResult.consumedEffectIds);+ chargeUsedEffects(runtime);}}if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);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.consumedEffectIds);- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);+ 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);}const winner = winnerFor(fighters) ?? "draw";@@ -398,16 +422,41 @@interface CancelledAttack {intent: AttackIntent;effectId: string;reason: "dodge" | "no_attack";- consumedEffectIds: string[];+ appliedEffects: AppliedEffect[];}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"]@@ -528,9 +577,9 @@}return {activeEffects: [],effectIndex: createEffectIndex(),- effectById: new Map(),+ usedEffects: new Set(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,skills,@@ -561,9 +610,8 @@}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {runtime.activeEffects.push(effect);- runtime.effectById.set(effect.id, effect);indexEffect(runtime.effectIndex, effect);}function expireInactive(runtime: Runtime, round: number): void {@@ -640,22 +688,29 @@function resolveAttackIntents(round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ orderEvents?: Map<string, AppliedOrderEffect>): AttackIntent[] {const intents: AttackIntent[] = [];for (const side of ["attacker", "defender"] as SideId[]) {const defenderSide = oppositeSide(side);let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ 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;+ orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });+ }const previousAttackCount = runtime.counters.attacks[side][attackerUnit];const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];intents.push({- id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,+ id: intentId,round,source: "normal",attackerSide: side,attackerUnit,@@ -672,27 +727,38 @@}return intents;}+// 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,roundStartTroops: DamageJob["roundStartTroops"],effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;- return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ return firstLivingUnit(order, defenderSide, roundStartTroops);}-function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {+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,+ index: EffectIndex,+ round: number+): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {- return effect.intent.value.map((value) => normalizeUnitType(String(value)));+ return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;}}@@ -739,12 +805,14 @@function extraSkillJobs(normalAttack: DamageJob,round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ capturesTrace: boolean): ExtraSkillJobsResult {const jobs: DamageJob[] = [];- const consumedEffectIds: string[] = [];+ const usedEffects: ActiveEffect[] = [];+ let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round);@@ -783,12 +851,21 @@}definitionIndex += 1;}if (jobs.length > firstJobIndex) {- consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));+ // The whole stacking group is charged whenever the selected effect spawned jobs,+ // regardless of duration constraints (extra attacks always deplete per firing).+ usedEffects.push(...effectGroup.effects);+ if (capturesTrace) {+ (appliedEffects ??= []).push({+ kind: "extra_attack",+ ...appliedEffectBase(effect),+ spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)+ });+ }}}- return { jobs, consumedEffectIds };+ return { jobs, usedEffects, appliedEffects };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {const selected: ExtraAttackEffectGroup[] = [];@@ -925,28 +1002,32 @@runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;}}-function attackDurationEffectIdsForJob(job: DamageJob, index: EffectIndex): string[] {- const ids: string[] = [];- for (const candidate of bucketCandidatesForJob(index, job)) {- if (candidate.effect.duration.type === "attack") ids.push(candidate.effect.id);+// 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();+}++// 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 {+ const used = runtime.usedEffects;+ for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {+ if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);}- for (const effect of index.controls) {- if (effect.duration.type !== "attack") continue;+ for (const effect of runtime.effectIndex.controls) {+ if (!hasAttackDurationConstraint(effect)) continue;const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") ids.push(effect.id);+ if (classification?.kind === "control") used.add(effect);}- return ids;+ used.add(winningControl);+ chargeUsedEffects(runtime);}-function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {- for (const consumed of consumedEffectIds) {- const effect = runtime.effectById.get(consumed);- if (effect) effect.uses += 1;- }-}-function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {const attackerAlive = total(fighters.attacker.troops) > 0;const defenderAlive = total(fighters.defender.troops) > 0;if (attackerAlive && !defenderAlive) return "attacker";Index: simulator/src/staticDamageProfile.ts===================================================================--- simulator/src/staticDamageProfile.ts prev run+++ simulator/src/staticDamageProfile.ts this run@@ -1,8 +1,8 @@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 } from "./effects";+import { currentEffectValuePct, sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;totalPct?: number;@@ -89,15 +89,11 @@}const duration = effect.duration;if (duration === undefined) return;- const durationType = duration.type ?? "battle";- if (durationType !== "battle") {+ if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}- if (duration.delay !== undefined && duration.delay !== 0) {- throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);- }}export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {return (STATIC_BUCKET_SET as Set<string>).has(bucket);@@ -265,11 +261,8 @@function emptyStats(): StatBlock {return { attack: 0, defense: 0, lethality: 0, health: 0 };}-function sourceLabel(effect: ActiveEffect): string {- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");-}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);Index: simulator/src/testcases.ts===================================================================--- simulator/src/testcases.ts prev run+++ simulator/src/testcases.ts this run@@ -3,9 +3,8 @@import { fileURLToPath } from "node:url";import {loadCalibrationComparison,- readCalibrationCase,type CalibrationCaseComparison,type SampleStats,sampleStats} from "./calibration";@@ -350,9 +349,8 @@return;}const stats = execution.simulatorStats;const gameResult = (entry as { game_report_result?: unknown }).game_report_result;- const calibration = readCalibrationCase(comparison, reportFile, testcaseId, { index });const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };const gameDistribution = distributionFromGameResult(gameResult);let game = gameDistribution@@ -376,17 +374,8 @@thresholds: comparison.thresholds}): undefined;if (gameStatAdjustment) game = gameStatAdjustment.adjusted;- const baseline = calibration?.nSim !== undefined && calibration.muSim !== undefined && calibration.sigmaSim !== undefined- ? compareOutcomeDistribution({- candidate: simulatorDistribution,- reference: { n: calibration.nSim, mu: calibration.muSim, sigma: calibration.sigmaSim },- initialTroops,- deterministic: result.randomness.deterministic,- thresholds: comparison.thresholds- })- : null;detail.result = result;detail.deterministic = execution.deterministic;detail.sampleCount = execution.sampleCount;@@ -395,26 +384,22 @@detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;detail.gameResult = gameResult;- detail.calibration = calibration;detail.visibility = visibilityFromResult(result);detail.diagnostics.push(...execution.diagnostics);if (!game) {report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "game_comparison", reason: "Missing game_report_result" });}- if (!baseline) {- report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "baseline_comparison", reason: "No matching baseline snapshot row" });- }report.counts.executed += 1;report.testcases[preparedCase.key!] = {file: reportFile,testcase_id: testcaseId,idx: index,deterministic: execution.deterministic,sampleCount: execution.sampleCount,game,- baseline,+ baseline: null,...(gameStatAdjustment ? { gameStatAdjustment: statAdjustmentForReport(gameStatAdjustment) } : {})};}@@ -631,9 +616,8 @@}export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));- applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.baseline).filter((value): value is ParityComparisonMetrics => !!value));}export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {report.artifactRoot = artifactRoot;Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -77,8 +77,9 @@export interface EffectDuration {type: "battle" | "round" | "attack";value: number;delay?: number;+ constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;}export interface EffectIntentDefinition {id: string;@@ -86,9 +87,13 @@value?: unknown;value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: { type?: string; value?: number; delay?: number };+ duration?: {+ turns?: { count?: number; delay?: number };+ rounds?: { count?: number; delay?: number };+ attacks?: { count?: number; delay?: number };+ };same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -171,8 +176,12 @@export type SimulationMode = "fast" | "standard" | "trace";export interface SimulationOptions {mode?: SimulationMode;+ // Whether a dodged / no_attack'd attack still charges (uses += 1) the attacker's+ // attack-constrained effects, as the game does. Default true.+ useEffectsOnDodge?: boolean;+ useEffectsOnNoAttack?: boolean;}export interface ResolvedTroopLine {id: string;@@ -244,8 +253,12 @@triggerDamageJobs?: TriggerDamageJobDefinition[];createdRound: number;startRound: number;duration: EffectDuration;+ // 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+ // unless useEffectsOnDodge/useEffectsOnNoAttack disable that.uses: number;stackingKey?: string;sameEffectStacking: SameEffectStacking;}@@ -309,24 +322,51 @@roundStartTroops: Record<SideId, Record<UnitType, number>>;armyTerm: number;atomicBuckets: Record<string, DamageBucketTrace>;aggregationGroups: Record<string, DamageAggregationGroupTrace>;- appliedEffects: AppliedEffectTrace[];+ appliedEffects: AppliedModifierEffect[];rejectedEffects: Array<{ effectId: string; reason: string }>;rawDamage: number;finalKills: number;}-export interface AppliedEffectTrace {+// 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.+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;+ // Config-level id (source.effectId ?? ActiveEffect.id).effectId: string;- bucket: string;- valuePct: number;source: string;sourceSide?: SideId;+}++export interface AppliedModifierEffect extends AppliedEffectBase {+ kind: "modifier";+ bucket: string;+ valuePct: number;stackingKey?: string;sameEffectStacking?: SameEffectStacking;}+export interface AppliedControlEffect extends AppliedEffectBase {+ kind: "control";+ reason: "dodge" | "no_attack";+}++export interface AppliedOrderEffect extends AppliedEffectBase {+ kind: "battle_order";+ chosenTarget: UnitType;+}++export interface AppliedExtraAttackEffect extends AppliedEffectBase {+ kind: "extra_attack";+ spawnedJobIds: string[];+}++export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;+export interface AttackOutcome {jobId: string;kind: DamageKind;sourceEffectId?: string;@@ -336,11 +376,9 @@defenderSide: SideId;defenderUnit: UnitType;kills: number;counterDeltas: CounterDelta[];- appliedEffectIds: string[];- appliedEffects: AppliedEffectTrace[];- consumedEffectIds: string[];+ appliedEffects: AppliedEffect[];cancelledBy?: string;cancelReason?: "dodge" | "no_attack";trace?: DamageEquationTrace;}
Show raw per-run patches
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json--- a/simulator/config/hero_definitions/Ahmose.json+++ b/simulator/config/hero_definitions/Ahmose.json@@ -19,8 +19,9 @@]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}},"ViperFormation/2": {@@ -39,9 +40,10 @@]},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"},@@ -60,9 +62,10 @@]},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"}@@ -113,8 +116,9 @@]},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}},"BladeOfLight/2": {@@ -130,8 +134,9 @@"applies_to": "target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "add"}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@@ -31,8 +31,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -64,9 +65,10 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"}@@ -99,8 +101,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [diff --git a/simulator/config/hero_definitions/Bahiti.json b/simulator/config/hero_definitions/Bahiti.json--- a/simulator/config/hero_definitions/Bahiti.json+++ b/simulator/config/hero_definitions/Bahiti.json@@ -47,8 +47,9 @@"applies_to": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}diff --git a/simulator/config/hero_definitions/Bradley.json b/simulator/config/hero_definitions/Bradley.json--- a/simulator/config/hero_definitions/Bradley.json+++ b/simulator/config/hero_definitions/Bradley.json@@ -76,8 +76,9 @@30],"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Gordon.json b/simulator/config/hero_definitions/Gordon.json--- a/simulator/config/hero_definitions/Gordon.json+++ b/simulator/config/hero_definitions/Gordon.json@@ -26,8 +26,9 @@]},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}},"VenomInfusion/2": {@@ -43,8 +44,9 @@"applies_to": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -71,8 +73,9 @@]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"},@@ -90,8 +93,9 @@"applies_vs": "self.any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -117,8 +121,9 @@"applies_to": "enemy.infantry"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"},@@ -135,8 +140,9 @@"applies_to": "enemy.marksman"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Greg.json b/simulator/config/hero_definitions/Greg.json--- a/simulator/config/hero_definitions/Greg.json+++ b/simulator/config/hero_definitions/Greg.json@@ -31,8 +31,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 3+ "turns": {+ "count": 3+ }}}}@@ -65,8 +66,9 @@"applies_vs": "self.any"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}}}diff --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@@ -24,8 +24,9 @@"applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -51,8 +52,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -60,7 +62,7 @@"target": "effect.applies_vs"}]- },+ },"AirDominance/2": {"type": "active.hero.damageTaken.up","value": [@@ -74,9 +76,10 @@"applies_to": "target"},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "attacks": {+ "count": 1,+ "delay": 1+ }}}}@@ -104,9 +107,10 @@]},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "attacks": {+ "count": 1,+ "delay": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/hero_definitions/Hector.json b/simulator/config/hero_definitions/Hector.json--- a/simulator/config/hero_definitions/Hector.json+++ b/simulator/config/hero_definitions/Hector.json@@ -26,8 +26,9 @@50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -55,8 +56,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 10+ "attacks": {+ "count": 10+ }},"value_evolution": {"type": "pct_decay",@@ -80,8 +82,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 10+ "attacks": {+ "count": 10+ }},"value_evolution": {"type": "pct_decay",@@ -118,8 +121,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/hero_definitions/Jeronimo.json b/simulator/config/hero_definitions/Jeronimo.json--- a/simulator/config/hero_definitions/Jeronimo.json+++ b/simulator/config/hero_definitions/Jeronimo.json@@ -60,8 +60,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}}}diff --git a/simulator/config/hero_definitions/Lynn.json b/simulator/config/hero_definitions/Lynn.json--- a/simulator/config/hero_definitions/Lynn.json+++ b/simulator/config/hero_definitions/Lynn.json@@ -26,8 +26,9 @@50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}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@@ -29,8 +29,9 @@"applies_to": "target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -63,8 +64,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [@@ -99,8 +101,9 @@50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Molly.json b/simulator/config/hero_definitions/Molly.json--- a/simulator/config/hero_definitions/Molly.json+++ b/simulator/config/hero_definitions/Molly.json@@ -29,8 +29,9 @@"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -62,8 +63,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/hero_definitions/Natalia.json b/simulator/config/hero_definitions/Natalia.json--- a/simulator/config/hero_definitions/Natalia.json+++ b/simulator/config/hero_definitions/Natalia.json@@ -29,8 +29,9 @@"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}diff --git a/simulator/config/hero_definitions/Norah.json b/simulator/config/hero_definitions/Norah.json--- a/simulator/config/hero_definitions/Norah.json+++ b/simulator/config/hero_definitions/Norah.json@@ -74,8 +74,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [@@ -109,8 +110,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}},"Momentum/2": {@@ -123,9 +125,10 @@25],"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }}}}diff --git a/simulator/config/hero_definitions/Philly.json b/simulator/config/hero_definitions/Philly.json--- a/simulator/config/hero_definitions/Philly.json+++ b/simulator/config/hero_definitions/Philly.json@@ -58,8 +58,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -96,8 +97,9 @@"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}diff --git a/simulator/config/hero_definitions/Reina.json b/simulator/config/hero_definitions/Reina.json--- a/simulator/config/hero_definitions/Reina.json+++ b/simulator/config/hero_definitions/Reina.json@@ -48,8 +48,9 @@"applies_to": "trigger"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -84,8 +85,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -25,9 +25,13 @@"applies_vs": "lancer"},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ },+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -55,9 +59,10 @@"applies_vs": "lancer"},"duration": {- "type": "turn",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ }},"same_effect_stacking": "max"}@@ -85,9 +90,10 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Wayne.json b/simulator/config/hero_definitions/Wayne.json--- a/simulator/config/hero_definitions/Wayne.json+++ b/simulator/config/hero_definitions/Wayne.json@@ -25,8 +25,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [@@ -60,8 +61,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -86,8 +88,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -127,8 +130,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/troop_skills.json b/simulator/config/troop_skills.json--- a/simulator/config/troop_skills.json+++ b/simulator/config/troop_skills.json@@ -97,8 +97,9 @@"applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -206,8 +207,9 @@]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -249,8 +251,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -290,8 +293,9 @@"applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -354,8 +358,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -403,8 +408,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -470,8 +476,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{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@@ -101,7 +101,9 @@}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);removeStaticProfileBucketEffects(effectIndex);- return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };}test("classifier routes up/down effects into neutral atomic buckets", () => {@@ -412,7 +414,7 @@assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);});-test("attack-duration bucket effects are consumed by the applicable attack job", () => {+test("attack-duration bucket effects are charged by the applicable attack job", () => {const oneAttackEffect = {...effect("active.hero.attack.up", "attacker", 100),id: "attack-up-active",@@ -421,11 +423,11 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });- assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);});-test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {const turnEffect = {...effect("active.hero.defense.down", "defender", 30),id: "bad-luck-like",@@ -435,10 +437,11 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });- assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);- assert.ok(outcome.appliedEffectIds?.includes("BadLuckStreak/1"));+ assert.ok(outcome.usedEffectIds.includes("bad-luck-like"));assert.deepEqual(outcome.appliedEffects, [{+ kind: "modifier",+ activeEffectId: "bad-luck-like",effectId: "BadLuckStreak/1",bucket: "active.hero.defense.down",valuePct: 30,@@ -449,7 +452,7 @@]);});-test("attack-duration effects are only consumed when they participate in the calculation", () => {+test("effects are only charged when they participate in the calculation", () => {const defenderOutgoingBuff = {...effect("active.hero.lethality.up", "defender", 100),id: "defender-outgoing-buff",@@ -459,7 +462,7 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);- assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);});test("pct attack value evolution uses the effect use count for the current bucket value", () => {@@ -481,7 +484,7 @@assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);- assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));});test("pct turn value evolution starts decaying after the first active turn", () => {@@ -506,7 +509,7 @@assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);});-test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {const weaker = {...effect("active.hero.lethality.up", "attacker", 50),id: "max-weaker",@@ -533,7 +536,7 @@assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);- assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));});test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {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@@ -71,6 +71,17 @@assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);});+test("loadSimulatorConfig rejects legacy duration shape", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "target" },+ duration: { type: "attack", value: 1 } as never+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /duration key type.*not supported/i);+});+test("simulator config source does not reference legacy effect metadata names", () => {const legacyEffectMetadataKey = ["effect", "op"].join("_");const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");@@ -166,7 +177,7 @@{type: "passive.attack.up",value: 10,- duration: { type: "round", value: 1 }+ duration: { turns: { count: 1 } }},{ type: "battle_start" });diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -173,6 +173,7 @@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);if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);if (!KNOWN_EFFECT_TYPES.has(type)) {@@ -263,6 +264,45 @@}}+function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {+ if (effect.duration === undefined) return;+ 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") {+ 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) {+ const value = duration[key];+ if (value === undefined) continue;+ validateNativeEffectDurationAxis(value, `${path}.${key}`);+ }+}++function validateNativeEffectDurationAxis(value: unknown, path: string): void {+ if (!value || typeof value !== "object" || Array.isArray(value)) {+ throw new Error(`native effect duration axis must be an object at ${path}`);+ }+ const axis = value as Record<string, unknown>;+ for (const key of Object.keys(axis)) {+ if (key !== "count" && key !== "delay") {+ throw new Error(`native effect duration axis key ${key} is not supported at ${path}; use count and/or delay`);+ }+ }+ if (axis.count === undefined && axis.delay === undefined) {+ throw new Error(`native effect duration axis must define count or delay at ${path}`);+ }+ if (axis.count !== undefined) validateIntegerRange(axis.count, 1, `${path}.count`);+ if (axis.delay !== undefined) validateIntegerRange(axis.delay, 0, `${path}.delay`);+}++function validateIntegerRange(value: unknown, min: number, path: string): void {+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {+ throw new Error(`native effect duration ${path} must be an integer >= ${min}`);+ }+}+function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {const path = `${file}:${skillId}.${effectId}`;if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -12,7 +12,7 @@import { UNIT_TYPES } from "./types";import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive } from "./effects";+import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,@@ -56,20 +56,17 @@export type DamageScratch = NumericDamageBuckets;-// Lean result of a single damage job: the correctness-relevant outputs (kills, consumed-// effect bookkeeping) plus, only when tracing, the recording detail. The heavy per-attack+// Lean result of a single damage job: kills plus, only when tracing, the recording detail.+// 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.export interface DamageResult {kills: number;- consumedEffectIds: string[];- appliedEffectIds?: string[];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 EMPTY_CONSUMED_EFFECT_IDS: string[] = [];const TROOPS_COUNT_TERM = factorTerm("troops.count");const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));@@ -109,7 +106,7 @@job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean }+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<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)@@ -128,9 +125,9 @@applyBucketValue(buckets, "troops.count", armyTerm);applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);- const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];- const consumedEffectIds = new Set<string>();+ 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)) {@@ -158,7 +155,7 @@}}- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);@@ -184,12 +181,8 @@}: undefined;- const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];-return {kills,- consumedEffectIds: returnedConsumedEffectIds,- appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,appliedEffects: traceEnabled ? appliedEffects : undefined,trace};@@ -201,7 +194,7 @@detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;for (const candidate of candidates) {@@ -216,12 +209,12 @@maxGroups.set(key, { selected: candidate, candidates: [candidate] });}} else {- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);}}@@ -246,6 +239,8 @@);if (appliedValuePct !== 0 && detail === "full") {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {+ kind: "modifier",+ activeEffectId: selected.effect.id,effectId: selected.effect.source.effectId ?? selected.effect.id,bucket: selected.bucket,valuePct: appliedValuePct,@@ -267,11 +262,11 @@detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);if (appliedValuePct !== 0) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ usedEffects.add(candidate.effect);} else if (detail === "full") {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });}@@ -284,12 +279,13 @@detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);if (appliedValuePct !== 0) {+ // The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.for (const candidate of candidates) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ usedEffects.add(candidate.effect);if (detail === "full" && candidate !== selected) {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });}@@ -355,6 +351,8 @@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,@@ -560,6 +558,3 @@return Number(((factor - 1) * 100).toFixed(12));}-function sourceLabel(effect: ActiveEffect): string {- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");-}diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -14,6 +14,7 @@import { normalizeUnitType } from "./normalize";export type Rng = () => number;+type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];interface CompiledTriggerSelectors {source: ParsedTriggerSelector;@@ -127,11 +128,17 @@return skill.heroName ?? skill.troopType ?? "global";}+export function sourceLabel(effect: ActiveEffect): string {+ 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;- if (effect.duration.type === "battle") return true;- if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);- return effect.uses < Math.max(1, effect.duration.value);+ return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));+}++export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {+ return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");}export function currentEffectValuePct(effect: ActiveEffect, round: number): number {@@ -229,13 +236,44 @@function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {if (!duration) return { type: "battle", value: 0 };- const rawType = duration.type === "turn" ? "round" : duration.type;- if (rawType === "round" || rawType === "attack" || rawType === "battle") {- return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 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;+}++function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {+ return {+ type,+ ...(value.count === undefined ? {} : { count: Number(value.count) }),+ ...(value.delay === undefined ? {} : { delay: Number(value.delay) })+ };+}+function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {return value === "max" ? "max" : "add";}diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts--- a/simulator/src/recorder.ts+++ b/simulator/src/recorder.ts@@ -1,4 +1,5 @@import type {+ AppliedEffect,AttackIntent,AttackOutcome,BattleTrace,@@ -11,7 +12,7 @@/*** Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop- * does all simulation-affecting work itself (kills, counters, effect consumption); the recorder+ * 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.*@@ -22,14 +23,15 @@export interface BattleRecorder {/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */readonly capturesTrace: boolean;- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void;- recordDamageJob(job: DamageJob, result: DamageResult): void;+ 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;readonly attacks: AttackOutcome[];readonly trace: BattleTrace | undefined;}const NO_ATTACKS: AttackOutcome[] = [];+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];export const NULL_RECORDER: BattleRecorder = {capturesTrace: false,@@ -62,7 +64,7 @@this.capturesTrace = trace !== undefined;}- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {this.attacks.push({jobId: `${intent.id}:cancelled`,kind: "normal",@@ -75,15 +77,13 @@{ 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" }],- appliedEffectIds: [],- appliedEffects: [],- consumedEffectIds,+ appliedEffects,cancelledBy: effectId,cancelReason: reason});}- recordDamageJob(job: DamageJob, result: DamageResult): void {+ 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);if (report) report.skillKills += result.kills;@@ -103,9 +103,7 @@{ side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },{ side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }],- appliedEffectIds: result.appliedEffectIds ?? [],- appliedEffects: result.appliedEffects ?? [],- consumedEffectIds: result.consumedEffectIds,+ appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),trace: result.trace});}@@ -114,3 +112,9 @@this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });}}++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];+}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@@ -7,7 +7,7 @@import { createSeededRng, chancePasses } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./resolve";import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";+import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";test("runPrepared reuses the compiled input seed when no override is supplied", () => {const config = loadSimulatorConfig();@@ -257,7 +257,7 @@type: "active.hero.lethality.up",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}@@ -296,7 +296,7 @@type: "active.hero.defense.up",value: 100,units: { applies_to: "target", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -311,6 +311,60 @@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(+ {+ maxRounds: 4,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DreamLancer: {+ name: "DreamLancer",+ troop_type: "lancer",+ skills: {+ EvenTurnAmbusher: {+ trigger: { type: "turn", every: 2 },+ effects: {+ order: {+ type: "attack_order",+ value: ["marksman", "infantry", "lancer"],+ units: { applies_to: "lancer", applies_vs: "marksman" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ NightmareTrace: {+ trigger: { type: "turn", every: 2, source: "lancer" },+ effects: {+ mark: {+ type: "active.hero.defense.down",+ value: 100,+ units: { applies_to: "target", applies_vs: "lancer" },+ duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { 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 round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));+ assert.equal(round4LancerAttack?.defenderUnit, "marksman");+ assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle(@@ -416,7 +470,7 @@type: "active.hero.lethality.up",value: 10,units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },- duration: { type: "battle", value: 1 },+ duration: {},same_effect_stacking: "add"}}@@ -472,7 +526,7 @@assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });});-test("attack-duration effects are consumed even when a normal attack is cancelled", () => {+test("cancelled attacks report the winning control as an applied effect", () => {const result = simulateBattle({maxRounds: 1,@@ -496,33 +550,89 @@type: "no_attack",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},debuff: {type: "active.hero.attack.up",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}}- })+ }),+ { mode: "trace" });const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);const cancelledAttack = cancelled!;- assert.equal(cancelledAttack.consumedEffectIds.length, 2);- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));+ assert.equal(cancelledAttack.appliedEffects.length, 1);+ const controlEvent = cancelledAttack.appliedEffects[0];+ assert.equal(controlEvent.kind, "control");+ assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");+ assert.ok(controlEvent.activeEffectId.includes(":cancel:"));assert.deepEqual(cancelledAttack.counterDeltas, [{ side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }]);});+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {+ // Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is+ // active. By default the cancelled attack charges the buff, so round 2 lands without it;+ // with useEffectsOnNoAttack:false the buff survives to boost round 2.+ const input = {+ maxRounds: 2,+ seed: "cancel-charge",+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: { SelfStunned: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ }+ };+ const config = minimalConfig({+ SelfStunned: {+ name: "SelfStunned",+ skills: {+ StunAndBuff: {+ trigger: { type: "battle_start" },+ effects: {+ stun: {+ type: "no_attack",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1, delay: 1 } }+ },+ buff: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ });++ const roundTwoKills = (options: SimulationOptions): number => {+ const result = simulateBattle(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;+ };++ const charged = roundTwoKills({});+ const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });+ 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({@@ -546,7 +656,7 @@cancel: {type: "no_attack",units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -637,7 +747,7 @@same_effect_stacking: "max",units: { applies_to: ["infantry"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -651,9 +761,11 @@const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));assert.notEqual(roundTwoNormalOutcome, undefined);- assert.equal(roundTwoNormalOutcome!.consumedEffectIds.length, 2);+ const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");+ assert.equal(extraAttackEvents.length, 1);+ assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);assert.notEqual(roundTwoSkillOutcome, undefined);- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);+ assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);});test("requires_effect is ignored by native simulator effect activation", () => {@@ -767,14 +879,14 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},hitMarksman: {type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -819,7 +931,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -861,7 +973,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -877,7 +989,7 @@type: "active.hero.defense.up",value: 50,units: { applies_to: "trigger.target", applies_vs: "trigger.source" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -919,14 +1031,14 @@value: 500,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},second: {type: "extra_skill_attack",value: 500,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1022,7 +1134,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source" } as never],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1061,7 +1173,7 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1098,7 +1210,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1145,7 +1257,7 @@value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1161,7 +1273,7 @@type: "no_attack",value: 100,units: { applies_to: "enemy.marksman", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1173,7 +1285,7 @@const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);- assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);+ assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];assert.deepEqual(skillJobsByRound, [0, 1]);@@ -1205,7 +1317,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1248,7 +1360,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}},@@ -1260,7 +1372,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1304,7 +1416,7 @@value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1382,7 +1494,7 @@value: 100,units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1422,7 +1534,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: ["lancer"] },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1465,7 +1577,7 @@value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}},@@ -1476,7 +1588,7 @@type: "type.skill.damage.up",value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1519,7 +1631,7 @@type: "active.hero.lethality.down",value: 50,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}@@ -1562,7 +1674,7 @@type: "active.hero.lethality.down",value: 50,units: { applies_to: "trigger.target", applies_vs: "any" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}@@ -1779,7 +1891,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1794,7 +1906,7 @@pause: {type: "no_attack",units: { applies_to: "enemy.marksman", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1938,14 +2050,14 @@same_effect_stacking,units: { applies_to: ["infantry"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "turn", value: 2 }+ duration: { turns: { count: 2 } }}: {type,value: 100,same_effect_stacking,units: { applies_to: ["infantry"], applies_vs: "any" },- duration: { type: "turn", value: 2 }+ duration: { turns: { count: 2 } }};return minimalConfig({[heroName]: {diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -1,5 +1,9 @@import type {ActiveEffect,+ AppliedControlEffect,+ AppliedEffect,+ AppliedExtraAttackEffect,+ AppliedOrderEffect,AttackIntent,AttackOutcome,BearBattleResult,@@ -27,11 +31,13 @@chancePasses,createSeededRng,currentEffectValuePct,+ hasAttackDurationConstraint,isEffectActive,oppositeSide,parseTriggerSelector,sideForTriggerRelation,skillMatchesTrigger,+ sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";@@ -49,7 +55,9 @@interface Runtime {activeEffects: ActiveEffect[];effectIndex: EffectIndex;- effectById: Map<string, ActiveEffect>;+ // 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>;staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;@@ -80,7 +88,8 @@interface ExtraSkillJobsResult {jobs: DamageJob[];- consumedEffectIds: string[];+ usedEffects: ActiveEffect[];+ appliedEffects?: AppliedExtraAttackEffect[];}interface BattleRun {@@ -222,7 +231,7 @@return {activeEffects,effectIndex,- effectById: new Map(activeEffects.map((e) => [e.id, e])),+ usedEffects: new Set(),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,@@ -299,6 +308,10 @@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+ };let rounds = 0;let score = 0;@@ -309,7 +322,9 @@expireInactive(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);- const intents = resolveAttackIntents(round, runtime, roundStartTroops);+ // Trace-only: battle_order applied events keyed by the intent they ordered.+ const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);const allJobs: DamageJob[] = []; // for recorderconst results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];@@ -324,9 +339,15 @@if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;runtime.attackControlCounts[control.reason] += 1;- const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);- cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });- consumeEffects(runtime, consumedEffectIds);+ if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ cancelled.push({+ intent,+ effectId: control.effect.id,+ reason: control.reason,+ appliedEffects: recorder.capturesTrace+ ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))+ : NO_APPLIED_EFFECTS+ });} else {pendingNormalJobs.push(job);}@@ -334,7 +355,7 @@// Phase 2: calculate each normal job; any extra_skill_attack effect it// uses spawns extra DamageJobs that are calculated immediately after.- // consumeEffects runs after each job so subsequent normal jobs see the+ // chargeUsedEffects runs after each job so subsequent normal jobs see the// correct uses count on any shared extra_skill_attack effects.for (const job of pendingNormalJobs) {allJobs.push(job);@@ -343,19 +364,21 @@effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects});- results.push({ job, result: normalResult });if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}- consumeEffects(runtime, normalResult.consumedEffectIds);-- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);- if (extraSkill.consumedEffectIds.length > 0) {- normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];- consumeEffects(runtime, extraSkill.consumedEffectIds);- }+ chargeUsedEffects(runtime);++ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);+ for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;+ results.push({+ job,+ result: normalResult,+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)+ });for (const extraJob of extraSkill.jobs) {allJobs.push(extraJob);@@ -364,13 +387,14 @@effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects});results.push({ job: extraJob, result: extraResult });if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {score += extraResult.kills;}- consumeEffects(runtime, extraResult.consumedEffectIds);+ chargeUsedEffects(runtime);}}@@ -378,8 +402,8 @@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.consumedEffectIds);- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);+ 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);}@@ -399,12 +423,37 @@intent: AttackIntent;effectId: string;reason: "dodge" | "no_attack";- consumedEffectIds: string[];+ appliedEffects: AppliedEffect[];}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(@@ -529,7 +578,7 @@return {activeEffects: [],effectIndex: createEffectIndex(),- effectById: new Map(),+ usedEffects: new Set(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,@@ -562,7 +611,6 @@function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {runtime.activeEffects.push(effect);- runtime.effectById.set(effect.id, effect);indexEffect(runtime.effectIndex, effect);}@@ -641,7 +689,8 @@function resolveAttackIntents(round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ orderEvents?: Map<string, AppliedOrderEffect>): AttackIntent[] {const intents: AttackIntent[] = [];for (const side of ["attacker", "defender"] as SideId[]) {@@ -649,12 +698,18 @@let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ 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;+ orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });+ }const previousAttackCount = runtime.counters.attacks[side][attackerUnit];const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];intents.push({- id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,+ id: intentId,round,source: "normal",attackerSide: side,@@ -673,6 +728,8 @@return intents;}+// 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,@@ -681,17 +738,26 @@effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;- return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ return firstLivingUnit(order, defenderSide, roundStartTroops);}-function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {+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,+ index: EffectIndex,+ round: number+): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {- return effect.intent.value.map((value) => normalizeUnitType(String(value)));+ return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;}@@ -740,10 +806,12 @@normalAttack: DamageJob,round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ capturesTrace: boolean): ExtraSkillJobsResult {const jobs: DamageJob[] = [];- const consumedEffectIds: string[] = [];+ const usedEffects: ActiveEffect[] = [];+ let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round@@ -784,10 +852,19 @@definitionIndex += 1;}if (jobs.length > firstJobIndex) {- consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));+ // The whole stacking group is charged whenever the selected effect spawned jobs,+ // regardless of duration constraints (extra attacks always deplete per firing).+ usedEffects.push(...effectGroup.effects);+ if (capturesTrace) {+ (appliedEffects ??= []).push({+ kind: "extra_attack",+ ...appliedEffectBase(effect),+ spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)+ });+ }}}- return { jobs, consumedEffectIds };+ return { jobs, usedEffects, appliedEffects };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {@@ -926,24 +1003,28 @@}}-function attackDurationEffectIdsForJob(job: DamageJob, index: EffectIndex): string[] {- const ids: string[] = [];- for (const candidate of bucketCandidatesForJob(index, job)) {- if (candidate.effect.duration.type === "attack") ids.push(candidate.effect.id);- }- for (const effect of index.controls) {- if (effect.duration.type !== "attack") continue;- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") ids.push(effect.id);- }- return ids;+// 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();}-function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {- for (const consumed of consumedEffectIds) {- const effect = runtime.effectById.get(consumed);- if (effect) effect.uses += 1;+// 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 {+ 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);}+ used.add(winningControl);+ chargeUsedEffects(runtime);}function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {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 } from "./effects";+import { currentEffectValuePct, sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;@@ -90,13 +90,9 @@const duration = effect.duration;if (duration === undefined) return;- const durationType = duration.type ?? "battle";- if (durationType !== "battle") {+ if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}- if (duration.delay !== undefined && duration.delay !== 0) {- throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);- }}export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {@@ -266,9 +262,6 @@return { attack: 0, defense: 0, lethality: 0, health: 0 };}-function sourceLabel(effect: ActiveEffect): string {- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");-}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);diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -78,6 +78,7 @@type: "battle" | "round" | "attack";value: number;delay?: number;+ constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;}export interface EffectIntentDefinition {@@ -87,7 +88,11 @@value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: { type?: string; value?: number; delay?: number };+ duration?: {+ turns?: { count?: number; delay?: number };+ rounds?: { count?: number; delay?: number };+ attacks?: { count?: number; delay?: number };+ };same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -172,6 +177,10 @@export interface SimulationOptions {mode?: SimulationMode;+ // Whether a dodged / no_attack'd attack still charges (uses += 1) the attacker's+ // attack-constrained effects, as the game does. Default true.+ useEffectsOnDodge?: boolean;+ useEffectsOnNoAttack?: boolean;}export interface ResolvedTroopLine {@@ -245,6 +254,10 @@createdRound: number;startRound: number;duration: EffectDuration;+ // 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+ // unless useEffectsOnDodge/useEffectsOnNoAttack disable that.uses: number;stackingKey?: string;sameEffectStacking: SameEffectStacking;@@ -310,22 +323,49 @@armyTerm: number;atomicBuckets: Record<string, DamageBucketTrace>;aggregationGroups: Record<string, DamageAggregationGroupTrace>;- appliedEffects: AppliedEffectTrace[];+ appliedEffects: AppliedModifierEffect[];rejectedEffects: Array<{ effectId: string; reason: string }>;rawDamage: number;finalKills: number;}-export interface AppliedEffectTrace {+// 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.+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;+ // Config-level id (source.effectId ?? ActiveEffect.id).effectId: string;- bucket: string;- valuePct: number;source: string;sourceSide?: SideId;+}++export interface AppliedModifierEffect extends AppliedEffectBase {+ kind: "modifier";+ bucket: string;+ valuePct: number;stackingKey?: string;sameEffectStacking?: SameEffectStacking;}+export interface AppliedControlEffect extends AppliedEffectBase {+ kind: "control";+ reason: "dodge" | "no_attack";+}++export interface AppliedOrderEffect extends AppliedEffectBase {+ kind: "battle_order";+ chosenTarget: UnitType;+}++export interface AppliedExtraAttackEffect extends AppliedEffectBase {+ kind: "extra_attack";+ spawnedJobIds: string[];+}++export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;+export interface AttackOutcome {jobId: string;kind: DamageKind;@@ -337,9 +377,7 @@defenderUnit: UnitType;kills: number;counterDeltas: CounterDelta[];- appliedEffectIds: string[];- appliedEffects: AppliedEffectTrace[];- consumedEffectIds: string[];+ appliedEffects: AppliedEffect[];cancelledBy?: string;cancelReason?: "dodge" | "no_attack";trace?: DamageEquationTrace;