← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error0.00%
Changed0
Improved0
Regressed0
Added0
Retired0
Testcase Delta
199 / 199
Code / Config Changes
Commits (b246cb30 → ed76d7ae)
ed76d7aeincpiddlyminx10/07/2026, 07:46:38
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Gordon.json===================================================================--- simulator/config/hero_definitions/Gordon.json prev run+++ simulator/config/hero_definitions/Gordon.json this run@@ -5,9 +5,9 @@"skills": {"VenomInfusion": {"description": "Every 2 attacks, Lancers deal X% extra damage and apply poison to the target for 1 turn. Poisoned enemies deal Y% less damage.","trigger": {- "type": "attack",+ "type": "turn","every": 2,"source": "lancer"},"effects": {Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -112,11 +112,13 @@"marksman"]},"duration": {- "attacks": {- "count": 1,+ "turns": {"delay": 1+ },+ "attacks": {+ "count": 1}},"trigger_damage_jobs": [{Index: simulator/src/classifierDamage.test.ts===================================================================--- simulator/src/classifierDamage.test.ts prev run+++ simulator/src/classifierDamage.test.ts this run@@ -3,10 +3,10 @@import { classifyEffectForJob } from "./classifier";import { calculateDamageJob } from "./damage";import { ATOMIC_BUCKETS } from "./damageBuckets";-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";-import { activateEffect } from "./effects";+import { createEffectIndex, indexEffect } from "./effectIndex";+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";import { ALL_UNIT_MASK, unitMask } from "./types";@@ -32,14 +32,16 @@source: { kind: "hero_skill", side: "attacker", heroName: "Example", skillId: "Scope", effectId: "scope/1" },intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask(["infantry"]) },appliesVs: { side: "defender", units: unitMask(["lancer"]) },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -77,14 +79,16 @@source,intent: { id: `${type}/1`, type, value: [valuePct] },ownerSide,kind: "modifier",- valuePct,+ initialValuePct: valuePct,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};}@@ -99,9 +103,8 @@if (!options.effectIndex) {for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);- removeStaticProfileBucketEffects(effectIndex);const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };}@@ -417,9 +420,9 @@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 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });@@ -431,9 +434,9 @@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" },- duration: { type: "round" as const, value: 1 }+ duration: { turns: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });@@ -455,9 +458,9 @@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 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });@@ -474,9 +477,11 @@type: "active.hero.attack.up",value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 15 }},- duration: { type: "attack" as const, value: 10 },+ duration: { attacks: { count: 10 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 2};const baseline = calculateIndexedDamageJob(job, simpleFighters(), [], { trace: true });@@ -498,9 +503,11 @@value_evolution: { type: "pct_decay", step: "turn", value: 15 }},createdRound: 0,startRound: 0,- duration: { type: "attack" as const, value: 10 }+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,+ duration: { attacks: { count: 10 } }};const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });const secondTurn = calculateIndexedDamageJob({ ...job, round: 2 }, simpleFighters(), [turnDecayAttackUp], { trace: true });@@ -512,9 +519,9 @@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 },+ duration: { attacks: { count: 3 } },stackingKey: "same-max-group",sameEffectStacking: "max" as const};const strongerButDecayed = {@@ -525,9 +532,11 @@type: "active.hero.lethality.up",value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 50 }},- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 1,stackingKey: "same-max-group",sameEffectStacking: "max" as const};Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -8,8 +8,17 @@import { loadSimulatorConfig } from "./config";import { loadSimulatorConfigFromDir } from "./config-node";import type { SkillFile } from "./types";+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];++ assert.deepEqual(effect.duration, {+ turns: { delay: 1 },+ attacks: { count: 1 }+ });+});+test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {const root = writeConfigWithTroopEffect({type: "active.hero.lethality.up",value: 10,Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -274,13 +274,13 @@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") {+ if (key !== "turns" && key !== "attacks") {throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);}}- for (const key of ["turns", "rounds", "attacks"] as const) {+ for (const key of ["turns", "attacks"] as const) {const value = duration[key];if (value === undefined) continue;validateNativeEffectDurationAxis(value, `${path}.${key}`);}Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -9,12 +9,11 @@SideId,UnitType} from "./types";import { UNIT_TYPES } from "./types";-import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";+import { advanceEffectAttackDelay, sourceLabel } from "./effects";+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,type StaticDamageBucket,type StaticDamageProfile,@@ -32,19 +31,13 @@placement: GroupPlacement;appliesTo?: DamageJob["kind"];}-interface BucketCandidate {- effect: ActiveEffect;- bucket: AtomicBucket;- valuePct: number;+interface MaxBucketEffectGroup {+ selected: ActiveEffect;+ effects: ActiveEffect[];}-interface MaxBucketCandidateGroup {- selected: BucketCandidate;- candidates: BucketCandidate[];-}-interface DamageExpressionResult {rawDamage: number;aggregationGroups: Record<string, DamageAggregationGroupTrace>;}@@ -128,36 +121,19 @@const appliedEffects: DamageEquationTrace["appliedEffects"] = [];const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();- const candidates: BucketCandidate[] = [];- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- if (!isEffectActive(candidate.effect, job.round)) continue;- handledCandidateEffectIds?.add(candidate.effect.id);- candidates.push({- effect: candidate.effect,- bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round)- });- }+ applyBucketEffects(+ damageEffectsForJob(options.effectIndex, job),+ job.round,+ buckets,+ detail,+ recordAppliedEffects,+ appliedEffects,+ rejectedEffects,+ usedEffects+ );- if (traceEnabled) {- for (const effect of options.effectIndex.all) {- if (handledCandidateEffectIds?.has(effect.id)) continue;- if (!isEffectActive(effect, job.round)) {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });- continue;- }- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "bucket" && classification.bucket) {- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);- }- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });- }- }-- applyBucketCandidates(candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];@@ -188,117 +164,123 @@trace};}-function applyBucketCandidates(- candidates: BucketCandidate[],+function applyBucketEffects(+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;- for (const candidate of candidates) {- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;+ for (const effect of effects) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {maxGroups ??= new Map();- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;+ const key = `${effect.intent.type}:${effect.stackingKey}`;const group = maxGroups.get(key);if (group) {- group.candidates.push(candidate);- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;+ group.effects.push(effect);+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;} else {- maxGroups.set(key, { selected: candidate, candidates: [candidate] });+ maxGroups.set(key, { selected: effect, effects: [effect] });}} else {- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the// applied percentage. Shared by the single-candidate and max-group paths.function applySelectedBucket(- selected: BucketCandidate,+ selected: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"]): number {const appliedValuePct = applyBucketValue(buckets,- selected.bucket,- selected.valuePct,- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",- detail === "full" ? sourceLabel(selected.effect) : "",- detail === "full" ? selected.effect.ownerSide : undefined,- selected.bucket,- selected.effect.stackingKey,- selected.effect.sameEffectStacking+ selected.intent.type,+ selected.getCurrentValuePct(round),+ detail === "full" ? selected.source.effectId ?? selected.id : "",+ detail === "full" ? sourceLabel(selected) : "",+ detail === "full" ? selected.ownerSide : undefined,+ selected.intent.type,+ selected.stackingKey,+ selected.sameEffectStacking);if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",- activeEffectId: selected.effect.id,- effectId: selected.effect.source.effectId ?? selected.effect.id,- bucket: selected.bucket,+ activeEffectId: selected.id,+ effectId: selected.source.effectId ?? selected.id,+ bucket: selected.intent.type,valuePct: appliedValuePct,- source: sourceLabel(selected.effect),- sourceSide: selected.effect.ownerSide,- sameEffectStacking: selected.effect.sameEffectStacking+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ sameEffectStacking: selected.sameEffectStacking};- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;appliedEffects.push(appliedEffect);}return appliedValuePct;}// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]// array and no suppressed-sibling bookkeeping.-function applyBucketCandidate(- candidate: BucketCandidate,+function applyBucketEffect(+ effect: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {- usedEffects.add(candidate.effect);+ usedEffects.add(effect);} else if (detail === "full") {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}-function applyBucketCandidateGroup(- selected: BucketCandidate,- candidates: BucketCandidate[],+function applyBucketEffectGroup(+ selected: ActiveEffect,+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.- for (const candidate of candidates) {- usedEffects.add(candidate.effect);- if (detail === "full" && candidate !== selected) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });+ for (const effect of effects) {+ usedEffects.add(effect);+ if (detail === "full" && effect !== selected) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });}}} else if (detail === "full") {- for (const candidate of candidates) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ for (const effect of effects) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}}Index: simulator/src/effectIndex.test.ts===================================================================--- simulator/src/effectIndex.test.ts prev run+++ simulator/src/effectIndex.test.ts this run@@ -1,8 +1,8 @@import assert from "node:assert/strict";import { test } from "node:test";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";+import { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";import { unitMask } from "./types";import type { ActiveEffect, DamageJob } from "./types";test("effect index returns bucket-tagged candidates from a direct job-shape lookup", () => {@@ -12,14 +12,16 @@source: { kind: "hero_skill", side: "attacker", effectId: "boost" },intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"};@@ -39,22 +41,19 @@defenderSide: "defender",defenderUnit: "lancer"};- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);});-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {+test("effect index excludes static-profile bucket effects", () => {const index = createEffectIndex();const passive = effect("passive.attack.up");const active = effect("active.hero.lethality.up");indexEffect(index, passive);indexEffect(index, active);- removeStaticProfileBucketEffects(index);-- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);- assert.deepEqual(index.all, [active]);+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);});function effect(type: string): ActiveEffect {return {@@ -62,14 +61,16 @@source: { kind: "hero_skill", side: "attacker", effectId: type },intent: { id: type, type, value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"};Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -1,17 +1,10 @@import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";import { unitsFromMask } from "./types";-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";-import { isStaticProfileBucket } from "./staticDamageProfile";+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";-export interface IndexedBucketEffect {- effect: ActiveEffect;- bucket: AtomicBucket;-}-export interface EffectIndex {- all: ActiveEffect[];- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;+ damageByJobShape: Array<ActiveEffect[] | undefined>;controls: ActiveEffect[];extraAttacks: ActiveEffect[];battleOrder: ActiveEffect[];}@@ -19,18 +12,16 @@const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;export function createEffectIndex(): EffectIndex {return {- all: [],damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),controls: [],extraAttacks: [],battleOrder: []};}export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {- index.all.push(effect);if (effect.kind === "control") {index.controls.push(effect);return;}@@ -47,55 +38,29 @@// Static-phase buckets (passive.*) are aggregated by the static damage profile, not the// per-job runtime path, so they must not enter the damage job-shape index.if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];- const appliesToUnits = unitsFromMask(effect.appliesTo.units);- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);- for (const jobKind of jobKinds) {- for (const appliesToUnit of appliesToUnits) {- for (const appliesVsUnit of appliesVsUnits) {- const slot =- definition.role === "attacker"- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);- const arr = index.damageByJobShape[slot];- const candidate = { effect, bucket: definition.path };- if (arr) arr.push(candidate);- else index.damageByJobShape[slot] = [candidate];- }- }+ for (const slot of shapeSlotsFor(effect, definition.path)) {+ const arr = index.damageByJobShape[slot];+ if (arr) arr.push(effect);+ else index.damageByJobShape[slot] = [effect];}}-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {- compactEffects(index.all, isActive);- compactEffects(index.controls, isActive);- compactEffects(index.extraAttacks, isActive);- compactEffects(index.battleOrder, isActive);- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- compactCandidates(arr, isActive);- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;+ const definition = bucketDefinition(effect.intent.type);+ return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";}-export function removeStaticProfileBucketEffects(index: EffectIndex): void {- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- let write = 0;- for (let read = 0; read < arr.length; read += 1) {- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }- }- arr.length = write;- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {+ effect.expired = true;+ if (effect.kind === "control") removeStable(index.controls, effect);+ else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);+ else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);}-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];}function damageJobShapeSlot(@@ -107,38 +72,48 @@): number {return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);}+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));++function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const key =+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 ++ (effect.appliesVs.units & 7);+ const cached = SHAPE_SLOTS_CACHE.get(key);+ if (cached) return cached;+ const slots = buildShapeSlots(effect, bucket);+ SHAPE_SLOTS_CACHE.set(key, slots);+ return slots;+}++function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const definition = bucketDefinition(bucket)!;+ const slots: number[] = [];+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];+ for (const jobKind of jobKinds) {+ for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {+ for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {+ slots.push(+ definition.role === "attacker"+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)+ );+ }+ }+ }+ return Uint8Array.from(slots);+}+function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {if (unit === "infantry") return 0;if (unit === "lancer") return 1;return 2;}-function isStaticProfileEffect(effect: ActiveEffect): boolean {- const definition = bucketDefinition(effect.intent.type);- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {+ const index = effects.indexOf(effect);+ if (index >= 0) effects.splice(index, 1);}--function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < effects.length; read += 1) {- const effect = effects[read];- if (!isActive(effect)) continue;- effects[write] = effect;- write += 1;- }- effects.length = write;-}--function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < candidates.length; read += 1) {- const candidate = candidates[read];- if (!isActive(candidate.effect)) continue;- candidates[write] = candidate;- write += 1;- }- candidates.length = write;-}Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -3,8 +3,9 @@ActiveEffectKind,AttackIntent,EffectDuration,EffectIntentDefinition,+ EvolvingActiveEffect,ResolvedSkill,ResolvedUnitScope,SameEffectStacking,SideId,@@ -13,10 +14,8 @@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;}@@ -87,16 +86,17 @@const ownerSide = skill.side;const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);const duration = normalizeDuration(intent.duration);- const delay = duration.delay ?? 0;+ const turnDelay = Math.max(0, duration.turns?.delay ?? 0);const effectKind = kindForIntent(intent);if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);}const sourceKey = skillActivationSourceKey(skill);- return {+ const effect: ActiveEffect = {id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,+ expired: false,source: {kind: skill.sourceKind,side: skill.side,heroName: skill.heroName,@@ -108,19 +108,33 @@},intent,ownerSide,kind: effectKind,- valuePct: typeof intent.value === "number" ? intent.value : undefined,+ initialValuePct: finiteNumberOrZero(intent.value),+ getCurrentValuePct: constantActiveEffectValuePct,appliesTo,appliesVs,triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,createdRound: round,- startRound: round + delay,+ startRound: Math.max(1, round + turnDelay),duration,+ remainingAttackDelay: duration.attacks?.delay ?? 0,uses: 0,stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)};+ const evolution = intent.value_evolution;+ if (!evolution) return effect;+ const evolvingEffect: EvolvingActiveEffect = {+ ...effect,+ valueEvolution: {+ type: evolution.type,+ step: evolution.step,+ amount: finiteNumberOrZero(evolution.value)+ },+ getCurrentValuePct: evolvingActiveEffectValuePct+ };+ return evolvingEffect;}function skillActivationSourceKey(skill: ResolvedSkill): string {return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";@@ -133,34 +147,60 @@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;- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {+ return effect.duration.attacks !== undefined;}-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {+ const count = effect.duration.attacks?.count;+ return count === undefined ? undefined : Math.max(1, count);}-export function currentEffectValuePct(effect: ActiveEffect, round: number): number {- const baseValue = Number(effect.valuePct ?? 0);- if (!Number.isFinite(baseValue)) return 0;- const evolution = effect.intent.value_evolution;- if (!evolution) return baseValue;- const firstActiveRound = Math.max(1, effect.startRound);- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;- const amount = Number(evolution.value ?? 0);- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;+export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {+ const turns = effect.duration.turns;+ if (!turns) return undefined;+ return {+ activationRound: effect.startRound,+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })+ };+}++export function isEffectAttackReady(effect: ActiveEffect): boolean {+ return effect.remainingAttackDelay <= 0;+}++// Returns true when the effect may apply to this eligible attack. The attack+// that consumes the final delay does not also consume the first active use.+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {+ const remaining = effect.remainingAttackDelay;+ if (remaining <= 0) return true;+ effect.remainingAttackDelay = remaining - 1;+ return false;+}++export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {+ return this.initialValuePct;+}++export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {+ const firstActiveRound = Math.max(1, this.startRound);+ const evolution = this.valueEvolution;+ const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;+ if (stepCount <= 0) return this.initialValuePct;if (evolution.type === "pct_decay") {- const factor = Math.max(0, 1 - amount / 100);- return baseValue * factor ** stepCount;+ const factor = Math.max(0, 1 - evolution.amount / 100);+ return this.initialValuePct * factor ** stepCount;}- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);- return baseValue;+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);+ return this.initialValuePct;}+function finiteNumberOrZero(value: unknown): number {+ return typeof value === "number" && Number.isFinite(value) ? value : 0;+}+export type TriggerSelectorRelation = "self" | "enemy";export interface ParsedTriggerSelector {relation: TriggerSelectorRelation;@@ -236,42 +276,17 @@return "modifier";}function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {- if (!duration) return { type: "battle", value: 0 };- const namedConstraints = normalizeNamedDurationConstraints(duration);- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };- return { type: "battle", value: 0 };+ if (!duration) return {};+ return {+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})+ };}-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 {+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {return {- type,...(value.count === undefined ? {} : { count: Number(value.count) }),...(value.delay === undefined ? {} : { delay: Number(value.delay) })};}Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -565,8 +565,141 @@assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});+test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedAfterPause: {+ name: "DelayedAfterPause",+ troop_type: "infantry",+ skills: {+ PauseFirstRound: {+ trigger: { type: "battle_start" },+ effects: {+ pause: {+ type: "no_attack",+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ DelayedAttackBudget: {+ trigger: { type: "turn", every: 99, first: 1 },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));+ const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ assert.equal(roundOne?.cancelReason, "no_attack");+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});++test("attack delay skips eligible attacks before the effect can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { marksman_t1: 1000000 },+ heroes: { DelayedExtra: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedExtra: {+ name: "DelayedExtra",+ troop_type: "marksman",+ skills: {+ NextAttack: {+ trigger: { type: "battle_start" },+ effects: {+ delayedExtra: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "self.marksman", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]+ }+ }+ }+ }+ }+ })+ );++ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");+ assert.equal(skillAttacks.length, 1);+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));+});++test("attack delay skips eligible damage jobs before a modifier can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedModifier: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedModifier: {+ name: "DelayedModifier",+ troop_type: "infantry",+ skills: {+ NextAttackBoost: {+ trigger: { type: "battle_start" },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter(+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"+ );+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle({Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -27,22 +27,25 @@import { calculateDamageJob, createFastDamageScratch, type DamageResult, type DamageScratch } from "./damage";import { createRecorder } from "./recorder";import {activateEffect,+ advanceEffectAttackDelay,chancePasses,+ constantActiveEffectValuePct,createSeededRng,- currentEffectValuePct,+ effectAttackUseLimit,+ effectRoundWindow,hasAttackDurationConstraint,- isEffectActive,+ isEffectAttackReady,oppositeSide,parseTriggerSelector,sideForTriggerRelation,skillMatchesTrigger,sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -52,10 +55,12 @@const BEAR_TROOP_ID = "bear_infantry";const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();interface Runtime {- activeEffects: ActiveEffect[];effectIndex: EffectIndex;+ preparedEffects: ActiveEffect[];+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;// Per-job scratch: effects that affected the job being calculated; drained by// chargeUsedEffects (uses += 1 each) after every job in every mode.usedEffects: Set<ActiveEffect>;staticDamageProfile?: StaticDamageProfile;@@ -214,29 +219,29 @@return { ...fighter, troops: { ...fighter.initialTroops } };}// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage-// profile, and drop static-profile effects from the per-job index.+// profile. Static-phase effects never enter the per-job index.function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");- addInputPassiveEffects(runtime, input.defender.passive, "defender");- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);- removeStaticProfileBucketEffects(runtime.effectIndex);+ const setupEffects = [+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")+ ];+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)-// and the index rebuilt from the clones; skills and the static profile are shared by reference.+// Clone a prepared template's mutable per-run effect state and rebuild the index from the+// clones; skills and the static profile are shared by reference.function cloneRuntime(template: Runtime, rng: Rng): Runtime {- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));- const effectIndex = createEffectIndex();- for (const effect of activeEffects) indexEffect(effectIndex, effect);- removeStaticProfileBucketEffects(effectIndex);- return {- activeEffects,- effectIndex,+ const runtime: Runtime = {+ effectIndex: createEffectIndex(),+ preparedEffects: [],+ activateEffectsByRound: [],+ expireEffectsByRound: [],usedEffects: new Set(),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,@@ -249,8 +254,12 @@attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }}};+ for (const templateEffect of template.preparedEffects) {+ addActiveEffect(runtime, { ...templateEffect, expired: false, uses: 0 });+ }+ return runtime;}function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {@@ -323,9 +332,9 @@for (let round = 1; round <= maxRounds; round += 1) {if (winnerFor(fighters)) break;rounds = round;const roundStartTroops = snapshotTroops(fighters);- expireInactive(runtime, round);+ processEffectSchedule(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);// Applied-effect events keyed by the intent they ordered.const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;@@ -345,9 +354,9 @@declaredNormalJobs.push({ intent, job });}for (const { intent, job } of declaredNormalJobs) {- const controls = applicableControls(job, round, runtime);+ const controls = applicableControls(job, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;pendingNormalJobs.push({ intent, job, control });} else {@@ -363,9 +372,11 @@if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;if (control) {runtime.attackControlCounts[control.reason] += 1;- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ if (useEffectsOnCancel[control.reason]) {+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);+ }cancelled.push({intent,effectId: control.effect.id,reason: control.reason,@@ -376,9 +387,9 @@continue;}allJobs.push(job);- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {+ const normalResult = calculateDamageJob(job, fighters, [], {trace: recorder.capturesTrace,recordAppliedEffects: recorder.capturesAppliedEffects,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,@@ -404,9 +415,9 @@for (const extraJob of extraSkill.jobs) {if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;allJobs.push(extraJob);- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {+ const extraResult = calculateDamageJob(extraJob, fighters, [], {trace: recorder.capturesTrace,recordAppliedEffects: recorder.capturesAppliedEffects,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,@@ -428,9 +439,9 @@}normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));for (const usedEffectGroup of extraSkill.usedEffectGroups) {if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);}}if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);@@ -609,10 +620,12 @@});}}return {- activeEffects: [],effectIndex: createEffectIndex(),+ preparedEffects: [],+ activateEffectsByRound: [],+ expireEffectsByRound: [],usedEffects: new Set(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,@@ -643,33 +656,55 @@};}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {- runtime.activeEffects.push(effect);- indexEffect(runtime.effectIndex, effect);+ if (!isRuntimeIndexableEffect(effect)) return;+ const window = effectRoundWindow(effect);+ if (!window) {+ indexEffect(runtime.effectIndex, effect);+ return;+ }+ if (window.expirationRound !== undefined) {+ if (window.expirationRound <= window.activationRound) {+ effect.expired = true;+ return;+ }+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);+ }+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);}-function expireInactive(runtime: Runtime, round: number): void {- let write = 0;- for (let read = 0; read < runtime.activeEffects.length; read += 1) {- const effect = runtime.activeEffects[read];- if (isEffectActive(effect, round)) {- runtime.activeEffects[write] = effect;- write += 1;+function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {+ const effects = schedule[round];+ if (effects) effects.push(effect);+ else schedule[round] = [effect];+}++function processEffectSchedule(runtime: Runtime, round: number): void {+ const expiring = runtime.expireEffectsByRound[round];+ if (expiring) {+ for (const effect of expiring) expireActiveEffect(runtime, effect);+ runtime.expireEffectsByRound[round] = undefined;+ }+ const activating = runtime.activateEffectsByRound[round];+ if (activating) {+ for (const effect of activating) {+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);}+ runtime.activateEffectsByRound[round] = undefined;}- runtime.activeEffects.length = write;- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));}-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {- if (!passive) return;+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {+ const effects: ActiveEffect[] = [];+ if (!passive) return effects;for (const stat of ["attack", "defense", "lethality", "health"] as const) {for (const direction of ["up", "down"] as const) {const valuePct = Number(passive[stat]?.[direction] ?? 0);if (!Number.isFinite(valuePct) || valuePct <= 0) continue;const bucket = `passive.${stat}.${direction}`;- addActiveEffect(runtime, {+ const effect: ActiveEffect = {id: `${side}:input_stat:${bucket}`,source: {kind: "input_stat",side,@@ -681,19 +716,24 @@value: valuePct},ownerSide: side,kind: "modifier",- valuePct,+ initialValuePct: valuePct,+ getCurrentValuePct: constantActiveEffectValuePct,appliesTo: { side, units: ALL_UNIT_MASK },appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"- });+ };+ addActiveEffect(runtime, effect);+ effects.push(effect);}}+ return effects;}function triggerSkills(triggerType: "battle_start" | "round_start" | "attack_declared",@@ -731,14 +771,14 @@const defenderSide = oppositeSide(side);let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);if (!defenderUnit) continue;const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;if (ordered) {- ordered.effect.uses += 1;+ chargeEffectUse(runtime, ordered.effect);orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });}const previousAttackCount = runtime.counters.attacks[side][attackerUnit];const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];@@ -771,9 +811,9 @@roundStartTroops: DamageJob["roundStartTroops"],effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;return firstLivingUnit(order, defenderSide, roundStartTroops);}function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {@@ -783,15 +823,16 @@function orderFromEffects(attackerUnit: UnitType,attackerSide: SideId,index: EffectIndex,- round: number+ advanceAttackDelay: boolean): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;+ if (effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;}@@ -801,25 +842,36 @@}function applicableControls(job: DamageJob,- round: number,runtime: Runtime-): { dodge?: Control; no_attack?: Control } {- const controls: { dodge?: Control; no_attack?: Control } = {};+): ApplicableControls {+ const controls: ApplicableControls = { attackDurationEffects: [] };for (const effect of runtime.effectIndex.controls) {- if (!isEffectActive(effect, round)) continue;const classification = classifyEffectForJob(effect, job);if (classification?.kind === "control" && classification.control) {- controls[classification.control] = { effect, reason: classification.control };+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);+ controls[classification.control] = {+ effect,+ reason: classification.control,+ attackDurationEffects: controls.attackDurationEffects+ };}}return controls;}+interface ApplicableControls {+ dodge?: Control;+ no_attack?: Control;+ attackDurationEffects: ActiveEffect[];+}+interface Control {effect: ActiveEffect;reason: "dodge" | "no_attack";+ attackDurationEffects: ActiveEffect[];}function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {return {@@ -846,9 +898,11 @@const jobs: DamageJob[] = [];const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),+ runtime.effectIndex.extraAttacks.filter(+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)+ ),round);for (const effectGroup of effectGroups) {const effect = effectGroup.selected;@@ -915,9 +969,9 @@continue;}const existing = selected[existingIndex];existing.effects.push(effect);- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;}return selected;}@@ -978,9 +1032,9 @@return undefined;}function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;const pct = Number(raw ?? 0);return Number.isFinite(pct) ? pct / 100 : 0;}@@ -1054,24 +1108,38 @@// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.function chargeUsedEffects(runtime: Runtime): void {if (runtime.usedEffects.size === 0) return;- for (const effect of runtime.usedEffects) effect.uses += 1;+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);runtime.usedEffects.clear();}+function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {+ effect.uses += 1;+ const limit = effectAttackUseLimit(effect);+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);+}++function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {+ if (effect.expired) return;+ expireEffectIndex(runtime.effectIndex, effect);+}+// A cancelled attack still charges the attacker's attack-constrained effects (the attack// happened, it just didn't land) plus the control that cancelled it, whatever its duration.-function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {+function chargeCancelledAttack(+ job: DamageJob,+ winningControl: ActiveEffect,+ controlEffects: ActiveEffect[],+ runtime: Runtime+): void {const used = runtime.usedEffects;- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) used.add(effect);}- for (const effect of runtime.effectIndex.controls) {- if (!hasAttackDurationConstraint(effect)) continue;- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") used.add(effect);- }+ for (const effect of controlEffects) used.add(effect);used.add(winningControl);chargeUsedEffects(runtime);}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, sourceLabel } from "./effects";+import { sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;totalPct?: number;@@ -89,9 +89,9 @@}const duration = effect.duration;if (duration === undefined) return;- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {+ if (duration.turns !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}}@@ -141,9 +141,9 @@const role = staticPassiveBucketRole(bucket)!;const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];for (const unit of UNIT_TYPES) {if (!unitMaskHas(effect.appliesTo.units, unit)) continue;- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };+ const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };if (effect.sameEffectStacking === "max" && effect.stackingKey) {const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;const group = groups.get(key);if (group) {Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -73,27 +73,26 @@export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;-export interface EffectDuration {- type: "battle" | "round" | "attack";- value: number;+export interface EffectDurationAxis {+ count?: number;delay?: number;- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;}+export interface EffectDuration {+ turns?: EffectDurationAxis;+ attacks?: EffectDurationAxis;+}+export interface EffectIntentDefinition {id: string;type: string;value?: unknown;value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: {- turns?: { count?: number; delay?: number };- rounds?: { count?: number; delay?: number };- attacks?: { count?: number; delay?: number };- };+ duration?: EffectDuration;same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -241,21 +240,26 @@}export interface ActiveEffect {id: string;+ expired?: boolean;source: EffectSource;intent: EffectIntentDefinition;ownerSide: SideId;kind: ActiveEffectKind;- valuePct?: number;+ initialValuePct: number;+ getCurrentValuePct(round: number): number;// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",// trigger-relative selectors, or concrete unit selectors; it does not accept "all".appliesTo: ResolvedUnitScope;appliesVs: ResolvedUnitScope;triggerDamageJobs?: TriggerDamageJobDefinition[];createdRound: number;startRound: number;duration: EffectDuration;+ // Eligible attacks remaining before an attacks.delay effect can apply. This is+ // runtime state resolved from config, separate from `uses` after activation.+ remainingAttackDelay: number;// Times this instance affected battle mechanics (damage bucket applied, control fired,// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"// value evolution read it; cancelled attacks still charge attack-constrained effects// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.@@ -263,8 +267,16 @@stackingKey?: string;sameEffectStacking: SameEffectStacking;}+export interface EvolvingActiveEffect extends ActiveEffect {+ valueEvolution: {+ type?: string;+ step?: string;+ amount: number;+ };+}+export interface AttackIntent {id: string;round: number;source: "normal";Index: simulator/src/workerPool.test.ts===================================================================--- simulator/src/workerPool.test.ts prev run+++ simulator/src/workerPool.test.ts this run@@ -0,0 +1,55 @@+import assert from "node:assert/strict";+import { test } from "node:test";++import { BatchWorkerPool, batchTasksByWeight, type BatchWorker } from "./workerPool";++class DeferredWorker implements BatchWorker<number, number> {+ calls: Array<{ id: number; tasks: number[] }> = [];+ private pending: Array<{ resolve: (results: number[]) => void; reject: (error: Error) => void }> = [];++ runBatch(id: number, tasks: number[]): Promise<number[]> {+ this.calls.push({ id, tasks });+ return new Promise((resolve, reject) => {+ this.pending.push({ resolve, reject });+ });+ }++ resolveNext(results: number[]): void {+ const pending = this.pending.shift();+ if (!pending) throw new Error("No pending worker batch");+ pending.resolve(results);+ }++ close(): void {+ while (this.pending.length > 0) this.pending.shift()!.reject(new Error("closed"));+ }+}++test("BatchWorkerPool refills idle workers from the queue", async () => {+ const workers = [new DeferredWorker(), new DeferredWorker()];+ const pool = new BatchWorkerPool<number, number>(2, (index) => workers[index]!);++ const first = pool.runBatch([1]);+ const second = pool.runBatch([2]);+ const third = pool.runBatch([3]);++ assert.deepEqual(workers.map((worker) => worker.calls.map((call) => call.tasks)), [[[1]], [[2]]]);++ workers[0]!.resolveNext([10]);+ assert.deepEqual(await first, [10]);+ assert.deepEqual(workers[0]!.calls.map((call) => call.tasks), [[1], [3]]);++ workers[1]!.resolveNext([20]);+ workers[0]!.resolveNext([30]);+ assert.deepEqual(await second, [20]);+ assert.deepEqual(await third, [30]);++ await pool.close();+});++test("batchTasksByWeight groups tasks without exceeding the target when possible", () => {+ assert.deepEqual(+ batchTasksByWeight([1, 1, 3, 1, 1], 2, (value) => value),+ [[1, 1], [3], [1, 1]]+ );+});Index: simulator/src/workerPool.ts===================================================================--- simulator/src/workerPool.ts prev run+++ simulator/src/workerPool.ts this run@@ -0,0 +1,111 @@+export interface BatchWorker<TTask, TResult, TProgress = never> {+ runBatch(id: number, tasks: TTask[], onProgress?: (progress: TProgress) => void): Promise<TResult[]>;+ close(): Promise<void> | void;+}++interface PendingBatch<TTask, TResult, TProgress> {+ tasks: TTask[];+ onProgress?: (progress: TProgress) => void;+ resolve: (result: TResult[]) => void;+ reject: (error: Error) => void;+}++interface WorkerState<TTask, TResult, TProgress> {+ worker: BatchWorker<TTask, TResult, TProgress>;+ idle: boolean;+ closed: boolean;+ inFlight?: PendingBatch<TTask, TResult, TProgress>;+}++export class BatchWorkerPool<TTask, TResult, TProgress = never> {+ private readonly workers: WorkerState<TTask, TResult, TProgress>[];+ private readonly queue: PendingBatch<TTask, TResult, TProgress>[] = [];+ private nextId = 1;+ private closed = false;++ constructor(size: number, createWorker: (index: number) => BatchWorker<TTask, TResult, TProgress>) {+ const count = Math.max(1, Math.floor(size));+ this.workers = Array.from({ length: count }, (_, index) => ({+ worker: createWorker(index),+ idle: true,+ closed: false+ }));+ }++ runBatch(tasks: TTask[], onProgress?: (progress: TProgress) => void): Promise<TResult[]> {+ if (this.closed) return Promise.reject(new Error("Worker pool is closed"));+ return new Promise((resolve, reject) => {+ this.queue.push({ tasks, onProgress, resolve, reject });+ this.pump();+ });+ }++ async close(): Promise<void> {+ if (this.closed) return;+ this.closed = true;+ while (this.queue.length > 0) this.queue.shift()!.reject(new Error("Worker pool closed before completing queued tasks"));+ await Promise.all(+ this.workers.map(async (state) => {+ state.closed = true;+ state.idle = false;+ if (state.inFlight) {+ state.inFlight.reject(new Error("Worker pool closed before completing in-flight task"));+ state.inFlight = undefined;+ }+ await state.worker.close();+ })+ );+ }++ private pump(): void {+ if (this.closed) return;+ for (const state of this.workers) {+ if (!state.idle || state.closed) continue;+ const pending = this.queue.shift();+ if (!pending) return;+ const id = this.nextId;+ this.nextId += 1;+ state.idle = false;+ state.inFlight = pending;+ void state.worker.runBatch(id, pending.tasks, pending.onProgress).then(+ (result) => {+ if (state.inFlight !== pending) return;+ state.inFlight = undefined;+ state.idle = true;+ pending.resolve(result);+ this.pump();+ },+ (error) => {+ if (state.inFlight !== pending) return;+ state.inFlight = undefined;+ state.idle = true;+ pending.reject(error instanceof Error ? error : new Error(String(error)));+ this.pump();+ }+ );+ }+ }+}++export function batchTasksByWeight<TTask>(+ tasks: TTask[],+ targetWeight: number,+ getWeight: (task: TTask) => number+): TTask[][] {+ const batches: TTask[][] = [];+ const maxWeight = Math.max(1, Math.floor(targetWeight));+ let current: TTask[] = [];+ let currentWeight = 0;+ for (const task of tasks) {+ const taskWeight = Math.max(1, Math.floor(getWeight(task)));+ if (current.length > 0 && currentWeight + taskWeight > maxWeight) {+ batches.push(current);+ current = [];+ currentWeight = 0;+ }+ current.push(task);+ currentWeight += taskWeight;+ }+ if (current.length > 0) batches.push(current);+ return batches;+}
Show raw per-run patches
Run A dirty state patch
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@@ -245,6 +245,86 @@assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);});+test("simulateBattle skips later attacks against same-round exhausted targets only", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ stats: {+ infantry: { attack: 100000, lethality: 100000 },+ lancer: { attack: 100000, lethality: 100000 }+ },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1 },+ stats: { infantry: { attack: 100000, lethality: 100000 } },+ heroes: {}+ }+ },+ minimalConfig(),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");++ assert.deepEqual(+ normalAttacks.map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:normal", "r1:defender:infantry:0:normal"]+ );+ assert.equal(normalAttacks[0]?.kills, 1);+ assert.equal(normalAttacks[1]?.kills, 100);+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.id === "r1:attacker:lancer:1:normal"), false);+ assert.equal(result.remaining.attacker.infantry, 0);+ assert.equal(result.remaining.defender.infantry, 0);+});++test("extra skill attacks against same-round exhausted targets are skipped entirely", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { marksman_t1: 100 },+ stats: { marksman: { attack: 100000, lethality: 100000 } },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 1 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ FollowUpShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");++ assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, {});+ assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);+ assert.equal(result.remaining.defender.lancer, 0);+});+test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {const config = loadSimulatorConfig();const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -88,10 +88,15 @@interface ExtraSkillJobsResult {jobs: DamageJob[];- usedEffects: ActiveEffect[];+ usedEffectGroups: ExtraSkillUsedEffectGroup[];appliedEffects?: AppliedExtraAttackEffect[];}+interface ExtraSkillUsedEffectGroup {+ sourceEffectId: string;+ effects: ActiveEffect[];+}+interface BattleRun {fighters: Record<SideId, ResolvedFighter>;runtime: Runtime;@@ -331,8 +336,9 @@// Phase 1: fire all attack_declared triggers for every intended attack before// evaluating any controls or damage, per the battle-core spec.- const pendingNormalJobs: DamageJob[] = [];+ const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; control?: Control }> = [];const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];+ const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);@@ -343,6 +349,20 @@const controls = applicableControls(job, round, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;+ pendingNormalJobs.push({ intent, job, control });+ } else {+ pendingNormalJobs.push({ intent, job });+ }+ }++ // Phase 2: calculate each normal job; any extra_skill_attack effect it+ // uses spawns extra DamageJobs that are calculated immediately after.+ // chargeUsedEffects runs after each job so subsequent normal jobs see the+ // correct uses count on any shared extra_skill_attack effects.+ for (const { intent, job, control } of pendingNormalJobs) {+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;++ if (control) {runtime.attackControlCounts[control.reason] += 1;if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);cancelled.push({@@ -353,16 +373,9 @@? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control)): NO_APPLIED_EFFECTS});- } else {- pendingNormalJobs.push(job);+ continue;}- }- // Phase 2: calculate each normal job; any extra_skill_attack effect it- // uses spawns extra DamageJobs that are calculated immediately after.- // 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,@@ -373,20 +386,24 @@capToDefenderTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects});+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}chargeUsedEffects(runtime);const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);- for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;- results.push({+ const processedExtraEffectIds = new Set<string>();+ const processedExtraJobIds = new Set<string>();+ const normalEntry: DamageJobResult = {job,result: normalResult,- extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)- });+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), undefined)+ };+ results.push(normalEntry);for (const extraJob of extraSkill.jobs) {+ if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;allJobs.push(extraJob);const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,@@ -397,15 +414,25 @@capToDefenderTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects});+ if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);+ processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");+ processedExtraJobIds.add(extraJob.id);+ if (extraJob.sourceEffectId) {+ runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;+ }results.push({ job: extraJob, result: extraResult });if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {score += extraResult.kills;}chargeUsedEffects(runtime);}+ normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));+ for (const usedEffectGroup of extraSkill.usedEffectGroups) {+ if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;+ for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ }}- if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);else commitRoundCounters(cancelled, results, runtime);@@ -817,7 +844,7 @@capturesTrace: boolean): ExtraSkillJobsResult {const jobs: DamageJob[] = [];- const usedEffects: ActiveEffect[] = [];+ const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),@@ -825,6 +852,7 @@);for (const effectGroup of effectGroups) {const effect = effectGroup.selected;+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;const definitions = effect.triggerDamageJobs ?? [];const firstJobIndex = jobs.length;let definitionIndex = 0;@@ -833,7 +861,6 @@const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);if (multiplier <= 0) continue;- const sourceEffectId = effect.source.effectId ?? effect.intent.id;const sourceSkillReportKey = reportKeyForEffectSource(effect.source);for (const source of sources) {if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;@@ -853,7 +880,6 @@sourceSkillReportKey,sourceMultiplier: multiplier});- runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;}}definitionIndex += 1;@@ -861,7 +887,7 @@if (jobs.length > firstJobIndex) {// The whole stacking group is charged whenever the selected effect spawned jobs,// regardless of duration constraints (extra attacks always deplete per firing).- usedEffects.push(...effectGroup.effects);+ usedEffectGroups.push({ sourceEffectId, effects: effectGroup.effects });if (capturesTrace) {(appliedEffects ??= []).push({kind: "extra_attack",@@ -871,7 +897,7 @@}}}- return { jobs, usedEffects, appliedEffects };+ return { jobs, usedEffectGroups, appliedEffects };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {@@ -958,29 +984,44 @@return Number.isFinite(pct) ? pct / 100 : 0;}-// Cap each defender unit's total kills this round to the troops available at round start, applied-// in job order. Mutates result.kills (and the trace's finalKills when present). This is-// simulation-affecting and runs in every mode, before commit and recording.-function capRoundKills(results: DamageJobResult[], roundStartTroops: DamageJob["roundStartTroops"]): void {- for (const side of ["attacker", "defender"] as SideId[]) {- for (const unit of UNIT_TYPES) {- const matching = results.filter((entry) => entry.job.defenderSide === side && entry.job.defenderUnit === unit && entry.result.kills > 0);- if (matching.length === 0) continue;- const available = Math.max(0, roundStartTroops[side][unit] ?? 0);- const totalKills = matching.reduce((sum, entry) => sum + entry.result.kills, 0);- if (totalKills <= available) continue;- let appliedKills = 0;- let rawRemaining = available;- for (const entry of matching) {- const rawKills = entry.result.kills;- entry.result.kills = Math.min(rawKills, Math.max(0, available - appliedKills));- appliedKills += entry.result.kills;- rawRemaining = Math.max(0, rawRemaining - rawKills);- if (rawRemaining === 0) appliedKills = available;- if (entry.result.trace) entry.result.trace.finalKills = entry.result.kills;- }- }+function emptyRoundTargetDamage(): Record<SideId, Record<UnitType, number>> {+ return { attacker: emptyTroops(), defender: emptyTroops() };+}++function targetExhausted(+ job: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"],+ roundTargetDamage: Record<SideId, Record<UnitType, number>>+): boolean {+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);+ return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;+}++function capJobToRemainingTarget(+ result: DamageResult,+ job: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"],+ roundTargetDamage: Record<SideId, Record<UnitType, number>>+): void {+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);+ const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];+ const remaining = Math.max(0, available - alreadyDamaged);+ result.kills = Math.min(result.kills, remaining);+ roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;+ if (result.trace) result.trace.finalKills = result.kills;+}++function filterExtraAppliedEffects(+ events: AppliedExtraAttackEffect[] | undefined,+ processedExtraJobIds: Set<string>+): AppliedExtraAttackEffect[] | undefined {+ if (!events) return undefined;+ const filtered: AppliedExtraAttackEffect[] = [];+ for (const event of events) {+ const spawnedJobIds = event.spawnedJobIds.filter((jobId) => processedExtraJobIds.has(jobId));+ if (spawnedJobIds.length > 0) filtered.push({ ...event, spawnedJobIds });}+ return filtered.length > 0 ? filtered : undefined;}// Apply the round's effects to fighter state: remove killed troops and bump attack/receiveddiff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts--- a/simulator/src/testcases.test.ts+++ b/simulator/src/testcases.test.ts@@ -40,8 +40,8 @@assert.equal(summary?.deterministic, false);assert.equal(summary?.sampleCount, 5);assert.equal(typeof summary?.game?.mu_candidate, "number");- assert.equal(typeof summary?.baseline?.mu_candidate, "number");- assert.deepEqual(Object.keys(summary?.game ?? {}), Object.keys(summary?.baseline ?? {}));+ assert.equal(summary?.baseline, null);+ assert.equal(report.counts.comparedToBaseline, 0);assert.equal("result" in (summary as object), false);assert.ok(detail?.result);assert.equal(detail?.simulatorStats?.n, 5);@@ -145,7 +145,7 @@assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");});-test("runTestcases keeps executed testcase and warns when baseline snapshot row is missing", () => {+test("runTestcases keeps executed testcase without warning when legacy baseline is unavailable", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);const summary = Object.values(report.testcases)[0];@@ -153,15 +153,16 @@assert.equal(report.counts.executed, 1);assert.equal(summary?.game?.n_candidate, 1);assert.equal(summary?.baseline, null);- assert.equal(report.warnings[0]?.stage, "baseline_comparison");+ assert.equal(report.counts.comparedToBaseline, 0);+ assert.deepEqual(report.warnings, []);});-test("applyComparisonQValues keeps game and baseline correction families separate", () => {+test("applyComparisonQValues corrects game comparisons only", () => {const firstGame = comparisonMetric(0.01);const secondGame = comparisonMetric(0.02);- const onlyBaseline = comparisonMetric(0.04);+ const ignoredBaseline = comparisonMetric(0.04);const testcases: Record<string, TestcaseSummaryEntry> = {- "testcases/a.json#0": summaryEntry("a", 0, firstGame, onlyBaseline),+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),"testcases/b.json#0": summaryEntry("b", 0, secondGame, null)};@@ -169,7 +170,7 @@assert.equal(firstGame.q, 0.02);assert.equal(secondGame.q, 0.02);- assert.equal(onlyBaseline.q, 0.04);+ assert.equal(ignoredBaseline.q, null);});test("calibration lookup supports simulator symlink and source testcase path variants", () => {@@ -185,7 +186,7 @@}});-test("duplicate no-hero testcase ids align calibration rows by file and case index", () => {+test("duplicate no-hero testcase ids keep game rows aligned by case index", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);@@ -198,15 +199,13 @@assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");assert.equal(first?.visibility.attacker.heroes.length, 0);assert.equal(second?.visibility.attacker.heroes.length, 0);- assert.equal(first?.calibration?.idx, 0);- assert.equal(second?.calibration?.idx, 1);- assert.equal(first?.calibration?.muGame, 3752);- assert.equal(second?.calibration?.muGame, 3652);- assert.equal(battleScoreDelta(first?.gameResult), first?.calibration?.muGame);- assert.equal(battleScoreDelta(second?.gameResult), second?.calibration?.muGame);+ assert.equal(first?.calibration, undefined);+ assert.equal(second?.calibration, undefined);+ assert.equal(battleScoreDelta(first?.gameResult), 3752);+ assert.equal(battleScoreDelta(second?.gameResult), 3652);});-test("no-hero simple testcase loads, runs, compares to calibration, and exposes aligned core fields", () => {+test("no-hero simple testcase loads, runs, compares to game result, and exposes aligned core fields", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);const entry = report.details[0];@@ -215,10 +214,10 @@assert.equal(report.counts.testcasesFound, 1);assert.equal(summary?.testcase_id, "simple_001");assert.equal(summary?.game?.n_reference, 1);- assert.equal(summary?.baseline?.n_reference, 100);+ assert.equal(summary?.baseline, null);assert.equal(entry?.visibility.attacker.heroes.length, 0);assert.equal(entry?.visibility.defender.heroes.length, 0);- assert.equal(entry?.calibration?.muGame, -186);+ assert.equal(entry?.calibration, undefined);assert.equal(battleScoreDelta(entry?.gameResult), -186);assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);});@@ -255,7 +254,7 @@assert.ok((entry?.result?.rounds ?? 0) > 100);});-test("runTestcases reports a parity summary from calibration JSON", () => {+test("runTestcases reports a game-focused parity summary without legacy baseline comparison", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);const row = Object.values(report.testcases)[0];@@ -265,21 +264,15 @@assert.equal(row?.testcase_id, "simple_001");assert.equal(row?.idx, 0);assert.equal(row?.game?.mu_reference, -186);- assert.equal(row?.baseline?.mu_reference, -186);- assert.equal(row?.baseline?.bias_raw, 0);- assert.equal(row?.baseline?.sem, 0);- assert.equal(row?.baseline?.p, null);- assert.equal(row?.baseline?.q, null);+ assert.equal(row?.baseline, null);assert.equal(typeof detail?.simulatorScoreDelta, "number");assert.equal(typeof row?.game?.bias_raw, "number");assert.equal(row?.game?.n_candidate, 1);assert.equal(typeof row?.game?.mu_candidate, "number");- assert.equal(typeof row?.baseline?.bias_raw, "number");assert.equal(typeof row?.game?.bias_raw, "number");- assert.equal(typeof row?.baseline?.passes, "boolean");assert.equal(typeof row?.game?.passes, "boolean");assert.equal(report.counts.comparedToGame, 1);- assert.equal(report.counts.comparedToBaseline, 1);+ assert.equal(report.counts.comparedToBaseline, 0);});test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {
Run B dirty state patch
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@@ -113,9 +113,11 @@]},"duration": {- "attacks": {- "count": 1,+ "turns": {"delay": 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@@ -4,8 +4,8 @@import { classifyEffectForJob } from "./classifier";import { calculateDamageJob } from "./damage";import { ATOMIC_BUCKETS } from "./damageBuckets";-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";-import { activateEffect } from "./effects";+import { createEffectIndex, indexEffect } from "./effectIndex";+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";import { ALL_UNIT_MASK, unitMask } from "./types";@@ -33,12 +33,14 @@intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask(["infantry"]) },appliesVs: { side: "defender", units: unitMask(["lancer"]) },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -78,12 +80,14 @@intent: { id: `${type}/1`, type, value: [valuePct] },ownerSide,kind: "modifier",- valuePct,+ initialValuePct: valuePct,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -100,7 +104,6 @@for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);- removeStaticProfileBucketEffects(effectIndex);const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };@@ -418,7 +421,7 @@const oneAttackEffect = {...effect("active.hero.attack.up", "attacker", 100),id: "attack-up-active",- duration: { type: "attack" as const, value: 1 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });@@ -432,7 +435,7 @@...effect("active.hero.defense.down", "defender", 30),id: "bad-luck-like",source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },- duration: { type: "round" as const, value: 1 }+ duration: { turns: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });@@ -456,7 +459,7 @@const defenderOutgoingBuff = {...effect("active.hero.lethality.up", "defender", 100),id: "defender-outgoing-buff",- duration: { type: "attack" as const, value: 1 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });@@ -475,7 +478,9 @@value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 15 }},- duration: { type: "attack" as const, value: 10 },+ duration: { attacks: { count: 10 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 2};@@ -499,7 +504,9 @@},createdRound: 0,startRound: 0,- duration: { type: "attack" as const, value: 10 }+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,+ duration: { attacks: { count: 10 } }};const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });@@ -513,7 +520,7 @@const weaker = {...effect("active.hero.lethality.up", "attacker", 50),id: "max-weaker",- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },stackingKey: "same-max-group",sameEffectStacking: "max" as const};@@ -526,7 +533,9 @@value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 50 }},- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 1,stackingKey: "same-max-group",sameEffectStacking: "max" as constdiff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts--- a/simulator/src/config.test.ts+++ b/simulator/src/config.test.ts@@ -9,6 +9,15 @@import { loadSimulatorConfigFromDir } from "./config-node";import type { SkillFile } from "./types";+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];++ assert.deepEqual(effect.duration, {+ turns: { delay: 1 },+ attacks: { count: 1 }+ });+});+test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {const root = writeConfigWithTroopEffect({type: "active.hero.lethality.up",diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -275,11 +275,11 @@const path = `${file}:${skillId}.${effectId}.duration`;const duration = effect.duration as Record<string, unknown>;for (const key of Object.keys(duration)) {- if (key !== "turns" && key !== "rounds" && key !== "attacks") {+ if (key !== "turns" && key !== "attacks") {throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);}}- for (const key of ["turns", "rounds", "attacks"] as const) {+ for (const key of ["turns", "attacks"] as const) {const value = duration[key];if (value === undefined) continue;validateNativeEffectDurationAxis(value, `${path}.${key}`);diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -10,10 +10,9 @@UnitType} from "./types";import { UNIT_TYPES } from "./types";-import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";+import { advanceEffectAttackDelay, sourceLabel } from "./effects";+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,type StaticDamageBucket,@@ -33,15 +32,9 @@appliesTo?: DamageJob["kind"];}-interface BucketCandidate {- effect: ActiveEffect;- bucket: AtomicBucket;- valuePct: number;-}--interface MaxBucketCandidateGroup {- selected: BucketCandidate;- candidates: BucketCandidate[];+interface MaxBucketEffectGroup {+ selected: ActiveEffect;+ effects: ActiveEffect[];}interface DamageExpressionResult {@@ -129,34 +122,17 @@const appliedEffects: DamageEquationTrace["appliedEffects"] = [];const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();- const candidates: BucketCandidate[] = [];- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- if (!isEffectActive(candidate.effect, job.round)) continue;- handledCandidateEffectIds?.add(candidate.effect.id);- candidates.push({- effect: candidate.effect,- bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round)- });- }-- if (traceEnabled) {- for (const effect of options.effectIndex.all) {- if (handledCandidateEffectIds?.has(effect.id)) continue;- if (!isEffectActive(effect, job.round)) {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });- continue;- }- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "bucket" && classification.bucket) {- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);- }- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });- }- }+ applyBucketEffects(+ damageEffectsForJob(options.effectIndex, job),+ job.round,+ buckets,+ detail,+ recordAppliedEffects,+ appliedEffects,+ rejectedEffects,+ usedEffects+ );- applyBucketCandidates(candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);@@ -189,8 +165,9 @@};}-function applyBucketCandidates(- candidates: BucketCandidate[],+function applyBucketEffects(+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,@@ -198,32 +175,35 @@rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;- for (const candidate of candidates) {- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;+ for (const effect of effects) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {maxGroups ??= new Map();- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;+ const key = `${effect.intent.type}:${effect.stackingKey}`;const group = maxGroups.get(key);if (group) {- group.candidates.push(candidate);- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;+ group.effects.push(effect);+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;} else {- maxGroups.set(key, { selected: candidate, candidates: [candidate] });+ maxGroups.set(key, { selected: effect, effects: [effect] });}} else {- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the// applied percentage. Shared by the single-candidate and max-group paths.function applySelectedBucket(- selected: BucketCandidate,+ selected: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,@@ -231,27 +211,27 @@): number {const appliedValuePct = applyBucketValue(buckets,- selected.bucket,- selected.valuePct,- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",- detail === "full" ? sourceLabel(selected.effect) : "",- detail === "full" ? selected.effect.ownerSide : undefined,- selected.bucket,- selected.effect.stackingKey,- selected.effect.sameEffectStacking+ selected.intent.type,+ selected.getCurrentValuePct(round),+ detail === "full" ? selected.source.effectId ?? selected.id : "",+ detail === "full" ? sourceLabel(selected) : "",+ detail === "full" ? selected.ownerSide : undefined,+ selected.intent.type,+ selected.stackingKey,+ selected.sameEffectStacking);if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",- activeEffectId: selected.effect.id,- effectId: selected.effect.source.effectId ?? selected.effect.id,- bucket: selected.bucket,+ activeEffectId: selected.id,+ effectId: selected.source.effectId ?? selected.id,+ bucket: selected.intent.type,valuePct: appliedValuePct,- source: sourceLabel(selected.effect),- sourceSide: selected.effect.ownerSide,- sameEffectStacking: selected.effect.sameEffectStacking+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ sameEffectStacking: selected.sameEffectStacking};- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;appliedEffects.push(appliedEffect);}return appliedValuePct;@@ -259,8 +239,9 @@// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]// array and no suppressed-sibling bookkeeping.-function applyBucketCandidate(- candidate: BucketCandidate,+function applyBucketEffect(+ effect: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,@@ -268,17 +249,18 @@rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {- usedEffects.add(candidate.effect);+ usedEffects.add(effect);} else if (detail === "full") {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}-function applyBucketCandidateGroup(- selected: BucketCandidate,- candidates: BucketCandidate[],+function applyBucketEffectGroup(+ selected: ActiveEffect,+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,@@ -286,18 +268,18 @@rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.- for (const candidate of candidates) {- usedEffects.add(candidate.effect);- if (detail === "full" && candidate !== selected) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });+ for (const effect of effects) {+ usedEffects.add(effect);+ if (detail === "full" && effect !== selected) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });}}} else if (detail === "full") {- for (const candidate of candidates) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ for (const effect of effects) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}}diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts--- a/simulator/src/effectIndex.test.ts+++ b/simulator/src/effectIndex.test.ts@@ -1,7 +1,7 @@import assert from "node:assert/strict";import { test } from "node:test";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";+import { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";import { unitMask } from "./types";import type { ActiveEffect, DamageJob } from "./types";@@ -13,12 +13,14 @@intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"@@ -40,20 +42,17 @@defenderUnit: "lancer"};- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);});-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {+test("effect index excludes static-profile bucket effects", () => {const index = createEffectIndex();const passive = effect("passive.attack.up");const active = effect("active.hero.lethality.up");indexEffect(index, passive);indexEffect(index, active);- removeStaticProfileBucketEffects(index);-- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);- assert.deepEqual(index.all, [active]);+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);});function effect(type: string): ActiveEffect {@@ -63,12 +62,14 @@intent: { id: type, type, value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts--- a/simulator/src/effectIndex.ts+++ b/simulator/src/effectIndex.ts@@ -1,16 +1,9 @@import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";import { unitsFromMask } from "./types";-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";-import { isStaticProfileBucket } from "./staticDamageProfile";--export interface IndexedBucketEffect {- effect: ActiveEffect;- bucket: AtomicBucket;-}+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";export interface EffectIndex {- all: ActiveEffect[];- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;+ damageByJobShape: Array<ActiveEffect[] | undefined>;controls: ActiveEffect[];extraAttacks: ActiveEffect[];battleOrder: ActiveEffect[];@@ -20,7 +13,6 @@export function createEffectIndex(): EffectIndex {return {- all: [],damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),controls: [],extraAttacks: [],@@ -29,7 +21,6 @@}export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {- index.all.push(effect);if (effect.kind === "control") {index.controls.push(effect);return;@@ -48,53 +39,27 @@// per-job runtime path, so they must not enter the damage job-shape index.if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];- const appliesToUnits = unitsFromMask(effect.appliesTo.units);- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);- for (const jobKind of jobKinds) {- for (const appliesToUnit of appliesToUnits) {- for (const appliesVsUnit of appliesVsUnits) {- const slot =- definition.role === "attacker"- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);- const arr = index.damageByJobShape[slot];- const candidate = { effect, bucket: definition.path };- if (arr) arr.push(candidate);- else index.damageByJobShape[slot] = [candidate];- }- }+ for (const slot of shapeSlotsFor(effect, definition.path)) {+ const arr = index.damageByJobShape[slot];+ if (arr) arr.push(effect);+ else index.damageByJobShape[slot] = [effect];}}-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {- compactEffects(index.all, isActive);- compactEffects(index.controls, isActive);- compactEffects(index.extraAttacks, isActive);- compactEffects(index.battleOrder, isActive);- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- compactCandidates(arr, isActive);- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;+ const definition = bucketDefinition(effect.intent.type);+ return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";}-export function removeStaticProfileBucketEffects(index: EffectIndex): void {- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- let write = 0;- for (let read = 0; read < arr.length; read += 1) {- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }- }- arr.length = write;- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {+ effect.expired = true;+ if (effect.kind === "control") removeStable(index.controls, effect);+ else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);+ else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);}-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];}@@ -108,6 +73,38 @@return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);}+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));++function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const key =+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 ++ (effect.appliesVs.units & 7);+ const cached = SHAPE_SLOTS_CACHE.get(key);+ if (cached) return cached;+ const slots = buildShapeSlots(effect, bucket);+ SHAPE_SLOTS_CACHE.set(key, slots);+ return slots;+}++function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const definition = bucketDefinition(bucket)!;+ const slots: number[] = [];+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];+ for (const jobKind of jobKinds) {+ for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {+ for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {+ slots.push(+ definition.role === "attacker"+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)+ );+ }+ }+ }+ return Uint8Array.from(slots);+}+function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {@@ -116,29 +113,7 @@return 2;}-function isStaticProfileEffect(effect: ActiveEffect): boolean {- const definition = bucketDefinition(effect.intent.type);- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));-}--function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < effects.length; read += 1) {- const effect = effects[read];- if (!isActive(effect)) continue;- effects[write] = effect;- write += 1;- }- effects.length = write;-}--function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < candidates.length; read += 1) {- const candidate = candidates[read];- if (!isActive(candidate.effect)) continue;- candidates[write] = candidate;- write += 1;- }- candidates.length = write;+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {+ const index = effects.indexOf(effect);+ if (index >= 0) effects.splice(index, 1);}diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -4,6 +4,7 @@AttackIntent,EffectDuration,EffectIntentDefinition,+ EvolvingActiveEffect,ResolvedSkill,ResolvedUnitScope,SameEffectStacking,@@ -14,8 +15,6 @@import { normalizeUnitType } from "./normalize";export type Rng = () => number;-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];-interface CompiledTriggerSelectors {source: ParsedTriggerSelector;target: ParsedTriggerSelector;@@ -88,14 +87,15 @@const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);const duration = normalizeDuration(intent.duration);- const delay = duration.delay ?? 0;+ const turnDelay = Math.max(0, duration.turns?.delay ?? 0);const effectKind = kindForIntent(intent);if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);}const sourceKey = skillActivationSourceKey(skill);- return {+ const effect: ActiveEffect = {id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,+ expired: false,source: {kind: skill.sourceKind,side: skill.side,@@ -109,17 +109,31 @@intent,ownerSide,kind: effectKind,- valuePct: typeof intent.value === "number" ? intent.value : undefined,+ initialValuePct: finiteNumberOrZero(intent.value),+ getCurrentValuePct: constantActiveEffectValuePct,appliesTo,appliesVs,triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,createdRound: round,- startRound: round + delay,+ startRound: Math.max(1, round + turnDelay),duration,+ remainingAttackDelay: duration.attacks?.delay ?? 0,uses: 0,stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)};+ const evolution = intent.value_evolution;+ if (!evolution) return effect;+ const evolvingEffect: EvolvingActiveEffect = {+ ...effect,+ valueEvolution: {+ type: evolution.type,+ step: evolution.step,+ amount: finiteNumberOrZero(evolution.value)+ },+ getCurrentValuePct: evolvingActiveEffectValuePct+ };+ return evolvingEffect;}function skillActivationSourceKey(skill: ResolvedSkill): string {@@ -134,30 +148,56 @@return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");}-export function isEffectActive(effect: ActiveEffect, round: number): boolean {- if (round < effect.startRound) return false;- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {+ return effect.duration.attacks !== undefined;}-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");-}--export function currentEffectValuePct(effect: ActiveEffect, round: number): number {- const baseValue = Number(effect.valuePct ?? 0);- if (!Number.isFinite(baseValue)) return 0;- const evolution = effect.intent.value_evolution;- if (!evolution) return baseValue;- const firstActiveRound = Math.max(1, effect.startRound);- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;- const amount = Number(evolution.value ?? 0);- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {+ const count = effect.duration.attacks?.count;+ return count === undefined ? undefined : Math.max(1, count);+}++export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {+ const turns = effect.duration.turns;+ if (!turns) return undefined;+ return {+ activationRound: effect.startRound,+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })+ };+}++export function isEffectAttackReady(effect: ActiveEffect): boolean {+ return effect.remainingAttackDelay <= 0;+}++// Returns true when the effect may apply to this eligible attack. The attack+// that consumes the final delay does not also consume the first active use.+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {+ const remaining = effect.remainingAttackDelay;+ if (remaining <= 0) return true;+ effect.remainingAttackDelay = remaining - 1;+ return false;+}++export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {+ return this.initialValuePct;+}++export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {+ const firstActiveRound = Math.max(1, this.startRound);+ const evolution = this.valueEvolution;+ const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;+ if (stepCount <= 0) return this.initialValuePct;if (evolution.type === "pct_decay") {- const factor = Math.max(0, 1 - amount / 100);- return baseValue * factor ** stepCount;+ const factor = Math.max(0, 1 - evolution.amount / 100);+ return this.initialValuePct * factor ** stepCount;}- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);- return baseValue;+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);+ return this.initialValuePct;+}++function finiteNumberOrZero(value: unknown): number {+ return typeof value === "number" && Number.isFinite(value) ? value : 0;}export type TriggerSelectorRelation = "self" | "enemy";@@ -237,40 +277,15 @@}function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {- if (!duration) return { type: "battle", value: 0 };- const namedConstraints = normalizeNamedDurationConstraints(duration);- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };- return { type: "battle", value: 0 };-}--function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {- return duration.constraints ?? [{ type: duration.type, count: duration.value }];-}--function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {- const delay = Math.max(0, constraint.delay ?? 0);- if (constraint.type === "battle") return true;- if (constraint.type === "round") {- const startRound = effect.createdRound + delay;- if (round < startRound) return false;- if (constraint.count === undefined) return true;- return round < startRound + Math.max(1, constraint.count);- }- if (constraint.count === undefined) return true;- return effect.uses < delay + Math.max(1, constraint.count);-}--function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {- const constraints: EffectDurationConstraint[] = [];- const turns = duration.turns ?? duration.rounds;- if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));- if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));- return constraints;+ if (!duration) return {};+ return {+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})+ };}-function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {return {- type,...(value.count === undefined ? {} : { count: Number(value.count) }),...(value.delay === undefined ? {} : { delay: Number(value.delay) })};diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -566,6 +566,139 @@assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});+test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedAfterPause: {+ name: "DelayedAfterPause",+ troop_type: "infantry",+ skills: {+ PauseFirstRound: {+ trigger: { type: "battle_start" },+ effects: {+ pause: {+ type: "no_attack",+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ DelayedAttackBudget: {+ trigger: { type: "turn", every: 99, first: 1 },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));+ const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ assert.equal(roundOne?.cancelReason, "no_attack");+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});++test("attack delay skips eligible attacks before the effect can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { marksman_t1: 1000000 },+ heroes: { DelayedExtra: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedExtra: {+ name: "DelayedExtra",+ troop_type: "marksman",+ skills: {+ NextAttack: {+ trigger: { type: "battle_start" },+ effects: {+ delayedExtra: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "self.marksman", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]+ }+ }+ }+ }+ }+ })+ );++ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");+ assert.equal(skillAttacks.length, 1);+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));+});++test("attack delay skips eligible damage jobs before a modifier can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedModifier: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedModifier: {+ name: "DelayedModifier",+ troop_type: "infantry",+ skills: {+ NextAttackBoost: {+ trigger: { type: "battle_start" },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter(+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"+ );+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle(diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -28,11 +28,14 @@import { createRecorder } from "./recorder";import {activateEffect,+ advanceEffectAttackDelay,chancePasses,+ constantActiveEffectValuePct,createSeededRng,- currentEffectValuePct,+ effectAttackUseLimit,+ effectRoundWindow,hasAttackDurationConstraint,- isEffectActive,+ isEffectAttackReady,oppositeSide,parseTriggerSelector,sideForTriggerRelation,@@ -41,7 +44,7 @@type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -53,8 +56,10 @@const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();interface Runtime {- activeEffects: ActiveEffect[];effectIndex: EffectIndex;+ preparedEffects: ActiveEffect[];+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;// Per-job scratch: effects that affected the job being calculated; drained by// chargeUsedEffects (uses += 1 each) after every job in every mode.usedEffects: Set<ActiveEffect>;@@ -215,27 +220,27 @@}// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage-// profile, and drop static-profile effects from the per-job index.+// profile. Static-phase effects never enter the per-job index.function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");- addInputPassiveEffects(runtime, input.defender.passive, "defender");- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);- removeStaticProfileBucketEffects(runtime.effectIndex);+ const setupEffects = [+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")+ ];+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)-// and the index rebuilt from the clones; skills and the static profile are shared by reference.+// Clone a prepared template's mutable per-run effect state and rebuild the index from the+// clones; skills and the static profile are shared by reference.function cloneRuntime(template: Runtime, rng: Rng): Runtime {- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));- const effectIndex = createEffectIndex();- for (const effect of activeEffects) indexEffect(effectIndex, effect);- removeStaticProfileBucketEffects(effectIndex);- return {- activeEffects,- effectIndex,+ const runtime: Runtime = {+ effectIndex: createEffectIndex(),+ preparedEffects: [],+ activateEffectsByRound: [],+ expireEffectsByRound: [],usedEffects: new Set(),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),@@ -250,6 +255,10 @@received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }}};+ for (const templateEffect of template.preparedEffects) {+ addActiveEffect(runtime, { ...templateEffect, expired: false, uses: 0 });+ }+ return runtime;}function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {@@ -324,7 +333,7 @@if (winnerFor(fighters)) break;rounds = round;const roundStartTroops = snapshotTroops(fighters);- expireInactive(runtime, round);+ processEffectSchedule(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);// Applied-effect events keyed by the intent they ordered.@@ -346,7 +355,7 @@}for (const { intent, job } of declaredNormalJobs) {- const controls = applicableControls(job, round, runtime);+ const controls = applicableControls(job, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;pendingNormalJobs.push({ intent, job, control });@@ -364,7 +373,9 @@if (control) {runtime.attackControlCounts[control.reason] += 1;- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ if (useEffectsOnCancel[control.reason]) {+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);+ }cancelled.push({intent,effectId: control.effect.id,@@ -377,7 +388,7 @@}allJobs.push(job);- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {+ const normalResult = calculateDamageJob(job, fighters, [], {trace: recorder.capturesTrace,recordAppliedEffects: recorder.capturesAppliedEffects,effectIndex: runtime.effectIndex,@@ -405,7 +416,7 @@for (const extraJob of extraSkill.jobs) {if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;allJobs.push(extraJob);- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {+ const extraResult = calculateDamageJob(extraJob, fighters, [], {trace: recorder.capturesTrace,recordAppliedEffects: recorder.capturesAppliedEffects,effectIndex: runtime.effectIndex,@@ -429,7 +440,7 @@normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));for (const usedEffectGroup of extraSkill.usedEffectGroups) {if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);}}@@ -610,8 +621,10 @@}}return {- activeEffects: [],effectIndex: createEffectIndex(),+ preparedEffects: [],+ activateEffectsByRound: [],+ expireEffectsByRound: [],usedEffects: new Set(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),@@ -644,31 +657,53 @@}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {- runtime.activeEffects.push(effect);- indexEffect(runtime.effectIndex, effect);+ if (!isRuntimeIndexableEffect(effect)) return;+ const window = effectRoundWindow(effect);+ if (!window) {+ indexEffect(runtime.effectIndex, effect);+ return;+ }+ if (window.expirationRound !== undefined) {+ if (window.expirationRound <= window.activationRound) {+ effect.expired = true;+ return;+ }+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);+ }+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);}-function expireInactive(runtime: Runtime, round: number): void {- let write = 0;- for (let read = 0; read < runtime.activeEffects.length; read += 1) {- const effect = runtime.activeEffects[read];- if (isEffectActive(effect, round)) {- runtime.activeEffects[write] = effect;- write += 1;+function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {+ const effects = schedule[round];+ if (effects) effects.push(effect);+ else schedule[round] = [effect];+}++function processEffectSchedule(runtime: Runtime, round: number): void {+ const expiring = runtime.expireEffectsByRound[round];+ if (expiring) {+ for (const effect of expiring) expireActiveEffect(runtime, effect);+ runtime.expireEffectsByRound[round] = undefined;+ }+ const activating = runtime.activateEffectsByRound[round];+ if (activating) {+ for (const effect of activating) {+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);}+ runtime.activateEffectsByRound[round] = undefined;}- runtime.activeEffects.length = write;- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));}-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {- if (!passive) return;+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {+ const effects: ActiveEffect[] = [];+ if (!passive) return effects;for (const stat of ["attack", "defense", "lethality", "health"] as const) {for (const direction of ["up", "down"] as const) {const valuePct = Number(passive[stat]?.[direction] ?? 0);if (!Number.isFinite(valuePct) || valuePct <= 0) continue;const bucket = `passive.${stat}.${direction}`;- addActiveEffect(runtime, {+ const effect: ActiveEffect = {id: `${side}:input_stat:${bucket}`,source: {kind: "input_stat",@@ -682,17 +717,22 @@},ownerSide: side,kind: "modifier",- valuePct,+ initialValuePct: valuePct,+ getCurrentValuePct: constantActiveEffectValuePct,appliesTo: { side, units: ALL_UNIT_MASK },appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"- });+ };+ addActiveEffect(runtime, effect);+ effects.push(effect);}}+ return effects;}function triggerSkills(@@ -732,12 +772,12 @@let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);if (!defenderUnit) continue;const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;if (ordered) {- ordered.effect.uses += 1;+ chargeEffectUse(runtime, ordered.effect);orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });}const previousAttackCount = runtime.counters.attacks[side][attackerUnit];@@ -772,7 +812,7 @@effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;return firstLivingUnit(order, defenderSide, roundStartTroops);}@@ -784,13 +824,14 @@attackerUnit: UnitType,attackerSide: SideId,index: EffectIndex,- round: number+ advanceAttackDelay: boolean): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;+ if (effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;@@ -802,23 +843,34 @@function applicableControls(job: DamageJob,- round: number,runtime: Runtime-): { dodge?: Control; no_attack?: Control } {- const controls: { dodge?: Control; no_attack?: Control } = {};+): ApplicableControls {+ const controls: ApplicableControls = { attackDurationEffects: [] };for (const effect of runtime.effectIndex.controls) {- if (!isEffectActive(effect, round)) continue;const classification = classifyEffectForJob(effect, job);if (classification?.kind === "control" && classification.control) {- controls[classification.control] = { effect, reason: classification.control };+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);+ controls[classification.control] = {+ effect,+ reason: classification.control,+ attackDurationEffects: controls.attackDurationEffects+ };}}return controls;}+interface ApplicableControls {+ dodge?: Control;+ no_attack?: Control;+ attackDurationEffects: ActiveEffect[];+}+interface Control {effect: ActiveEffect;reason: "dodge" | "no_attack";+ attackDurationEffects: ActiveEffect[];}function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {@@ -847,7 +899,9 @@const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),+ runtime.effectIndex.extraAttacks.filter(+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)+ ),round);for (const effectGroup of effectGroups) {@@ -916,7 +970,7 @@}const existing = selected[existingIndex];existing.effects.push(effect);- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;}return selected;}@@ -979,7 +1033,7 @@}function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;const pct = Number(raw ?? 0);return Number.isFinite(pct) ? pct / 100 : 0;}@@ -1055,22 +1109,36 @@// 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;+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);runtime.usedEffects.clear();}+function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {+ effect.uses += 1;+ const limit = effectAttackUseLimit(effect);+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);+}++function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {+ if (effect.expired) return;+ expireEffectIndex(runtime.effectIndex, effect);+}+// A cancelled attack still charges the attacker's attack-constrained effects (the attack// happened, it just didn't land) plus the control that cancelled it, whatever its duration.-function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {+function chargeCancelledAttack(+ job: DamageJob,+ winningControl: ActiveEffect,+ controlEffects: ActiveEffect[],+ runtime: Runtime+): void {const used = runtime.usedEffects;- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);- }- for (const effect of runtime.effectIndex.controls) {- if (!hasAttackDurationConstraint(effect)) continue;- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") used.add(effect);+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) used.add(effect);}+ for (const effect of controlEffects) used.add(effect);used.add(winningControl);chargeUsedEffects(runtime);}diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts--- a/simulator/src/staticDamageProfile.ts+++ b/simulator/src/staticDamageProfile.ts@@ -1,7 +1,7 @@import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";-import { currentEffectValuePct, sourceLabel } from "./effects";+import { sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;@@ -90,7 +90,7 @@const duration = effect.duration;if (duration === undefined) return;- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {+ if (duration.turns !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}}@@ -142,7 +142,7 @@const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];for (const unit of UNIT_TYPES) {if (!unitMaskHas(effect.appliesTo.units, unit)) continue;- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };+ const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };if (effect.sameEffectStacking === "max" && effect.stackingKey) {const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;const group = groups.get(key);diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -74,11 +74,14 @@export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;-export interface EffectDuration {- type: "battle" | "round" | "attack";- value: number;+export interface EffectDurationAxis {+ count?: number;delay?: number;- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;+}++export interface EffectDuration {+ turns?: EffectDurationAxis;+ attacks?: EffectDurationAxis;}export interface EffectIntentDefinition {@@ -88,11 +91,7 @@value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: {- turns?: { count?: number; delay?: number };- rounds?: { count?: number; delay?: number };- attacks?: { count?: number; delay?: number };- };+ duration?: EffectDuration;same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -242,11 +241,13 @@export interface ActiveEffect {id: string;+ expired?: boolean;source: EffectSource;intent: EffectIntentDefinition;ownerSide: SideId;kind: ActiveEffectKind;- valuePct?: number;+ initialValuePct: number;+ getCurrentValuePct(round: number): number;// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",// trigger-relative selectors, or concrete unit selectors; it does not accept "all".appliesTo: ResolvedUnitScope;@@ -255,6 +256,9 @@createdRound: number;startRound: number;duration: EffectDuration;+ // Eligible attacks remaining before an attacks.delay effect can apply. This is+ // runtime state resolved from config, separate from `uses` after activation.+ remainingAttackDelay: number;// Times this instance affected battle mechanics (damage bucket applied, control fired,// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"// value evolution read it; cancelled attacks still charge attack-constrained effects@@ -264,6 +268,14 @@sameEffectStacking: SameEffectStacking;}+export interface EvolvingActiveEffect extends ActiveEffect {+ valueEvolution: {+ type?: string;+ step?: string;+ amount: number;+ };+}+export interface AttackIntent {id: string;round: number;