← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed16
Improved8
Regressed8
Added0
Retired0
Testcase Delta
199 / 199
Code / Config Changes
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Alonso.json===================================================================--- simulator/config/hero_definitions/Alonso.json prev run+++ simulator/config/hero_definitions/Alonso.json this run@@ -12,10 +12,9 @@40,40,40,40- ],- "source": "self.any"+ ]},"effects": {"Onslaught/1": {"type": "active.hero.lethality.up",@@ -25,12 +24,8 @@30,40,50],- "units": {- "applies_to": "trigger",- "applies_vs": "trigger.target"- },"duration": {"turns": {"count": 1}Index: simulator/config/hero_definitions/Mia.json===================================================================--- simulator/config/hero_definitions/Mia.json prev run+++ simulator/config/hero_definitions/Mia.json this run@@ -29,9 +29,10 @@"applies_to": "target"},"duration": {"turns": {- "count": 1+ "count": 1,+ "delay": 1}},"same_effect_stacking": "max"}Index: simulator/src/classifierDamage.test.ts===================================================================--- simulator/src/classifierDamage.test.ts prev run+++ simulator/src/classifierDamage.test.ts this run@@ -32,8 +32,9 @@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",+ bucketIndex: -1,initialValuePct: 25,getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask(["infantry"]) },appliesVs: { side: "defender", units: unitMask(["lancer"]) },@@ -79,8 +80,9 @@source,intent: { id: `${type}/1`, type, value: [valuePct] },ownerSide,kind: "modifier",+ bucketIndex: -1,initialValuePct: valuePct,getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },@@ -103,11 +105,11 @@if (!options.effectIndex) {for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const usedEffects = options.usedEffects ?? [];const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });- return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };+ return { ...result, usedEffectIds: usedEffects.map((usedEffect) => usedEffect.id) };}test("classifier routes up/down effects into neutral atomic buckets", () => {assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -4,9 +4,8 @@DamageBucketTrace,DamageEquationTrace,DamageJob,ResolvedFighter,- SameEffectStacking,SideId,UnitType} from "./types";import { UNIT_TYPES } from "./types";@@ -59,15 +58,27 @@}const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};-const TROOPS_COUNT_TERM = factorTerm("troops.count");-const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");+const TROOPS_COUNT_INDEX = BUCKET_IDS["troops.count"];+const SOURCE_EXTRA_SKILL_INDEX = BUCKET_IDS["source.extraSkill"];const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;+const factorSlots = (terms: DamageFactorTerm[], kind: DamageJob["kind"]): Int32Array =>+ Int32Array.from(terms.filter((term) => !term.appliesTo || term.appliesTo === kind).map((term) => term.bucket));+const DEFAULT_NUMERATOR_SLOTS = { normal: factorSlots(DEFAULT_NUMERATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_NUMERATOR_TERMS, "skill") };+const DEFAULT_DENOMINATOR_SLOTS = { normal: factorSlots(DEFAULT_DENOMINATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_DENOMINATOR_TERMS, "skill") };+const PROFILED_NUMERATOR_SLOTS = { normal: factorSlots(PROFILED_NUMERATOR_TERMS, "normal"), skill: factorSlots(PROFILED_NUMERATOR_TERMS, "skill") };+const PROFILED_DENOMINATOR_SLOTS = DEFAULT_DENOMINATOR_SLOTS;+const BUCKET_UPDATE_BY_INDEX = Uint8Array.from(+ ATOMIC_BUCKETS.map((bucket) => {+ const update = BUCKET_DEFINITIONS[bucket].update;+ return update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2;+ })+);export class DamageAggregationError extends Error {readonly groupId: string;readonly round: number;@@ -98,9 +109,9 @@export function calculateDamageJob(job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }+ options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");// The damage math is one path; `trace` only decides whether we also capture the (expensive)// per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.@@ -115,14 +126,14 @@const minInitialArmy = Math.max(1, Math.min(totalTroops(attacker.initialTroops), totalTroops(defender.initialTroops)));const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));const needsTraceBuckets = traceEnabled;const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);- applyBucketValue(buckets, "troops.count", armyTerm);- applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);+ buckets.factors[TROOPS_COUNT_INDEX] = Math.max(0, armyTerm);+ buckets.factors[SOURCE_EXTRA_SKILL_INDEX] = Math.max(0, job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);const appliedEffects: DamageEquationTrace["appliedEffects"] = [];const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const usedEffects = options.usedEffects ?? [];applyBucketEffects(damageEffectsForJob(options.effectIndex, job),job.round,buckets,@@ -135,12 +146,9 @@if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);- const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];- const traceBuckets = needsTraceBuckets ? toTraceBuckets(buckets, staticTraceEntries) : undefined;- const expressionDetail = traceEnabled ? "full" : "fast";- const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, expressionDetail, staticProfile);+ const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, detail, staticProfile);const uncappedKills = Math.max(0, rawDamage);const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);const trace = traceEnabled? {@@ -148,9 +156,12 @@attacker: { ...job.roundStartTroops.attacker },defender: { ...job.roundStartTroops.defender }},armyTerm,- atomicBuckets: traceBuckets ?? toTraceBuckets(buckets, staticTraceEntries),+ atomicBuckets: toTraceBuckets(buckets, [+ staticProfile.offense[job.attackerSide][job.attackerUnit],+ staticProfile.defense[job.defenderSide][job.defenderUnit]+ ]),aggregationGroups,appliedEffects,rejectedEffects,rawDamage,@@ -172,9 +183,9 @@detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;for (const effect of effects) {if (effect.expired) continue;@@ -208,19 +219,25 @@detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"]): number {- const appliedValuePct = applyBucketValue(- buckets,- 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- );+ const appliedValuePct = selected.getCurrentValuePct(round);+ const bucketIndex = selected.bucketIndex;+ const update = BUCKET_UPDATE_BY_INDEX[bucketIndex];+ if (update === 0) buckets.factors[bucketIndex] = Math.max(0, appliedValuePct);+ else if (update === 1) buckets.factors[bucketIndex] *= 1 + appliedValuePct / 100;+ else buckets.factors[bucketIndex] += appliedValuePct / 100;+ if (detail === "full") {+ buckets.contributors?.[bucketIndex].push({+ effectId: selected.source.effectId ?? selected.id,+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ valuePct: appliedValuePct,+ bucket: selected.intent.type,+ stackingKey: selected.stackingKey,+ sameEffectStacking: selected.sameEffectStacking+ });+ }if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",activeEffectId: selected.id,@@ -246,13 +263,13 @@detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {- usedEffects.add(effect);+ usedEffects.push(effect);} else if (detail === "full") {rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}@@ -265,15 +282,15 @@detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {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 effect of effects) {- usedEffects.add(effect);+ usedEffects.push(effect);if (detail === "full" && effect !== selected) {rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });}}@@ -312,28 +329,8 @@buckets.factors.fill(1);return buckets;}-function applyBucketValue(- buckets: NumericDamageBuckets,- bucketName: AtomicBucket,- value: number,- effectId = "",- source = "",- sourceSide: SideId | undefined = undefined,- traceBucketName = bucketName,- stackingKey?: string,- sameEffectStacking: SameEffectStacking = "add"-): number {- const index = BUCKET_IDS[bucketName];- const definition = BUCKET_DEFINITIONS[bucketName];- if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);- else if (definition.update === "multiply_pct_factor") buckets.factors[index] *= 1 + value / 100;- else buckets.factors[index] += value / 100;- if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });- return value;-}-function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {if (!bucket.startsWith("passive.") || !term) continue;for (const contributor of term.contributors) {@@ -353,12 +350,13 @@}function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);+ const factors = buckets.factors;let numerator = 1;- for (const term of DEFAULT_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];let denominator = 1;- for (const term of DEFAULT_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];return {rawDamage: numerator / denominator,aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS};@@ -369,17 +367,20 @@buckets: NumericDamageBuckets,detail: DamageDetail,staticProfile: StaticDamageProfile): DamageExpressionResult {- validateProfiledStaticFactors(job, staticProfile);+ const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];+ const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];+ if (!offense.playerFactorsValid || !defense.playerFactorsValid) validateProfiledStaticFactors(job, staticProfile);+ const factors = buckets.factors;let numerator =- valueForTerm(TROOPS_COUNT_TERM, job, buckets) *- valueForTerm(SOURCE_EXTRA_SKILL_TERM, job, buckets) *- staticProfile.offense[job.attackerSide][job.attackerUnit].factor *- staticProfile.defense[job.defenderSide][job.defenderUnit].factor;- for (const term of PROFILED_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ factors[TROOPS_COUNT_INDEX] *+ factors[SOURCE_EXTRA_SKILL_INDEX] *+ offense.factor *+ defense.factor;+ for (const slot of PROFILED_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];let denominator = 100;- for (const term of PROFILED_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];return {rawDamage: numerator / denominator,aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS};@@ -406,13 +407,8 @@contributors: term?.contributors ?? []});}-function valueForTerm(term: DamageFactorTerm, job: DamageJob, buckets: NumericDamageBuckets): number {- if (term.appliesTo && term.appliesTo !== job.kind) return 1;- return buckets.factors[term.bucket];-}-function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);for (const term of [...DEFAULT_NUMERATOR_TERMS, ...DEFAULT_DENOMINATOR_TERMS]) {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 { createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";+import { cloneEffectIndex, 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,8 +12,9 @@source: { kind: "hero_skill", side: "attacker", effectId: "boost" },intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",+ bucketIndex: -1,initialValuePct: 25,getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },@@ -54,15 +55,35 @@assert.deepEqual(damageEffectsForJob(index, job()), [active]);});+test("cloned effect index remaps shared effects while owning its bucket arrays", () => {+ const index = createEffectIndex();+ const original = effect("active.hero.lethality.up");+ const replacement = { ...original };+ indexEffect(index, original);++ const clone = cloneEffectIndex(index, (candidate) => {+ assert.equal(candidate, original);+ return replacement;+ });+ const originalCandidates = damageEffectsForJob(index, job());+ const clonedCandidates = damageEffectsForJob(clone, job());++ assert.notEqual(clonedCandidates, originalCandidates);+ assert.deepEqual(clonedCandidates, [replacement]);+ clonedCandidates.push(effect("active.hero.attack.up"));+ assert.deepEqual(originalCandidates, [original]);+});+function effect(type: string): ActiveEffect {return {id: type,source: { kind: "hero_skill", side: "attacker", effectId: type },intent: { id: type, type, value: 25 },ownerSide: "attacker",kind: "modifier",+ bucketIndex: -1,initialValuePct: 25,getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -8,9 +8,10 @@extraAttacks: ActiveEffect[];battleOrder: ActiveEffect[];}-const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));export function createEffectIndex(): EffectIndex {return {damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),@@ -19,8 +20,17 @@battleOrder: []};}+export function cloneEffectIndex(index: EffectIndex, cloneEffect: (effect: ActiveEffect) => ActiveEffect): EffectIndex {+ return {+ damageByJobShape: index.damageByJobShape.map((effects) => effects?.map(cloneEffect)),+ controls: index.controls.map(cloneEffect),+ extraAttacks: index.extraAttacks.map(cloneEffect),+ battleOrder: index.battleOrder.map(cloneEffect)+ };+}+export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {if (effect.kind === "control") {index.controls.push(effect);return;@@ -37,8 +47,9 @@const definition = bucketDefinition(effect.intent.type);// Static-phase buckets (passive.*) are aggregated by the static damage profile, not the// per-job runtime path, so they must not enter the damage job-shape index.if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;+ effect.bucketIndex = BUCKET_INDEX.get(definition.path) ?? -1;for (const slot of shapeSlotsFor(effect, definition.path)) {const arr = index.damageByJobShape[slot];if (arr) arr.push(effect);@@ -62,9 +73,9 @@export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];}-function damageJobShapeSlot(+export function damageJobShapeSlot(jobKind: DamageKind,attackerSide: SideId,attackerUnit: UnitType,defenderSide: SideId,@@ -73,10 +84,8 @@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);Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -10,18 +10,33 @@SameEffectStacking,SideId,UnitType} from "./types";-import { ALL_UNIT_MASK, unitMask } from "./types";+import { ALL_UNIT_MASK, unitMask, unitMaskHas } from "./types";import { normalizeUnitType } from "./normalize";export type Rng = () => number;-interface CompiledTriggerSelectors {- source: ParsedTriggerSelector;- target: ParsedTriggerSelector;+interface CompiledActivation {+ idPrefix: string;+ source: ActiveEffect["source"];+ ownerSide: SideId;+ kind: ActiveEffectKind;+ initialValuePct: number;+ getCurrentValuePct: ActiveEffect["getCurrentValuePct"];+ valueEvolution?: EvolvingActiveEffect["valueEvolution"];+ triggerDamageJobs: ActiveEffect["triggerDamageJobs"];+ duration: EffectDuration;+ turnDelay: number;+ attackDelay: number;+ stackingKey: string;+ sameEffectStacking: SameEffectStacking;+ staticAppliesTo: ResolvedUnitScope;+ staticAppliesVs: ResolvedUnitScope;+ intentScoped: boolean;}-const TRIGGER_SELECTOR_CACHE = new WeakMap<ResolvedSkill, CompiledTriggerSelectors>();+const ACTIVATION_CACHE = new WeakMap<EffectIntentDefinition, CompiledActivation>();+const INTENT_SCOPED_VALUES = new Set(["trigger.source", "trigger", "trigger.target", "target"]);export function oppositeSide(side: SideId): SideId {return side === "attacker" ? "defender" : "attacker";}@@ -38,34 +53,49 @@if (triggerType === "attack_declared" && trigger.type !== "attack") return false;if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every, trigger.first)) return false;if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every, trigger.first)) return false;if (!intent) return true;- const selectors = compiledTriggerSelectors(skill);+ const selectors = compiledTriggerForSkill(skill);return (- triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&- triggerSelectorMatches(selectors.target, skill.side, intent.defenderSide, intent.defenderUnit)+ triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&+ triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit));}-function compiledTriggerSelectors(skill: ResolvedSkill): CompiledTriggerSelectors {- const cached = TRIGGER_SELECTOR_CACHE.get(skill);- if (cached) return cached;+export function compiledTriggerForSkill(skill: ResolvedSkill): NonNullable<ResolvedSkill["compiledTrigger"]> {+ const cached = skill.compiledTrigger;+ if (cached && cached.definition === skill.trigger && cached.side === skill.side && cached.level === skill.level) return cached;const compiled = {- source: parseTriggerSelector(skill.trigger.source, "self"),- target: parseTriggerSelector(skill.trigger.target, "enemy")+ definition: skill.trigger,+ side: skill.side,+ level: skill.level,+ source: compileTriggerScope(skill, skill.trigger.source, "self"),+ target: compileTriggerScope(skill, skill.trigger.target, "enemy"),+ probabilityPct: resolvedProbabilityPct(skill)};- TRIGGER_SELECTOR_CACHE.set(skill, compiled);+ skill.compiledTrigger = compiled;return compiled;}-export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+function compileTriggerScope(skill: ResolvedSkill, value: unknown, defaultRelation: TriggerSelectorRelation): ResolvedUnitScope {+ const selector = parseTriggerSelector(value, defaultRelation);+ return {+ side: sideForTriggerRelation(skill.side, selector.relation),+ units: selector.units ? unitMask(selector.units) : ALL_UNIT_MASK+ };+}++function resolvedProbabilityPct(skill: ResolvedSkill): number {const probability = skill.trigger.probability;- if (probability === undefined) return true;- const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);- if (!Number.isFinite(value) || value <= 0) return false;+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability ?? 100);+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;+}++export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+ const value = compiledTriggerForSkill(skill).probabilityPct;+ if (value <= 0) return false;if (value >= 100) return true;- const threshold = value / 100;- return rng() < threshold;+ return rng() < value / 100;}export function createSeededRng(seed: string | number = "simulator-default"): Rng {let state = hashSeed(String(seed));@@ -80,23 +110,24 @@if (previous < first) return true;return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);}-export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+function compiledActivation(skill: ResolvedSkill, intent: EffectIntentDefinition): CompiledActivation {+ const cached = ACTIVATION_CACHE.get(intent);+ if (cached) return cached;const units = intent.units ?? {};const ownerSide = skill.side;- const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);- const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);+ const staticAppliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", undefined, ownerSide);+ const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);const duration = normalizeDuration(intent.duration);- const 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);- const effect: ActiveEffect = {- id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,- expired: false,+ const evolution = intent.value_evolution;+ const compiled: CompiledActivation = {+ idPrefix: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r`,source: {kind: skill.sourceKind,side: skill.side,heroName: skill.heroName,@@ -105,36 +136,67 @@skillId: skill.id,skillName: skill.name,effectId: intent.id},- intent,ownerSide,kind: effectKind,initialValuePct: finiteNumberOrZero(intent.value),- getCurrentValuePct: constantActiveEffectValuePct,+ getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,+ valueEvolution: evolution+ ? {+ type: evolution.type,+ step: evolution.step,+ amount: finiteNumberOrZero(evolution.value)+ }+ : undefined,+ triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ duration,+ turnDelay: Math.max(0, duration.turns?.delay ?? 0),+ attackDelay: duration.attacks?.delay ?? 0,+ stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,+ sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking),+ staticAppliesTo,+ staticAppliesVs,+ intentScoped:+ (typeof units.applies_to === "string" && INTENT_SCOPED_VALUES.has(units.applies_to)) ||+ (typeof units.applies_vs === "string" && INTENT_SCOPED_VALUES.has(units.applies_vs))+ };+ ACTIVATION_CACHE.set(intent, compiled);+ return compiled;+}++export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+ const compiled = compiledActivation(skill, intent);+ let appliesTo = compiled.staticAppliesTo;+ let appliesVs = compiled.staticAppliesVs;+ if (compiled.intentScoped && attackIntent) {+ const units = intent.units ?? {};+ appliesTo = resolveUnitScope(units.applies_to, compiled.ownerSide, "applies_to", attackIntent, compiled.ownerSide);+ appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, compiled.ownerSide);+ }+ const effect: ActiveEffect = {+ id: compiled.idPrefix + round + ":" + (attackIntent?.id ?? "global"),+ expired: false,+ source: compiled.source,+ intent,+ ownerSide: compiled.ownerSide,+ kind: compiled.kind,+ bucketIndex: -1,+ initialValuePct: compiled.initialValuePct,+ getCurrentValuePct: compiled.getCurrentValuePct,appliesTo,appliesVs,- triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ triggerDamageJobs: compiled.triggerDamageJobs,createdRound: round,- startRound: Math.max(1, round + turnDelay),- duration,- remainingAttackDelay: duration.attacks?.delay ?? 0,+ startRound: Math.max(1, round + compiled.turnDelay),+ duration: compiled.duration,+ remainingAttackDelay: compiled.attackDelay,uses: 0,- stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,- sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)+ stackingKey: compiled.stackingKey,+ sameEffectStacking: compiled.sameEffectStacking};- 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;+ if (!compiled.valueEvolution) return effect;+ return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;}function skillActivationSourceKey(skill: ResolvedSkill): string {return skill.heroInstanceId ?? skill.heroName ?? skill.troopType ?? "global";@@ -222,11 +284,10 @@export function sideForTriggerRelation(skillSide: SideId, relation: TriggerSelectorRelation): SideId {return relation === "self" ? skillSide : oppositeSide(skillSide);}-function triggerSelectorMatches(selector: ParsedTriggerSelector, skillSide: SideId, actualSide: SideId, actualUnit: UnitType): boolean {- if (sideForTriggerRelation(skillSide, selector.relation) !== actualSide) return false;- return selector.units === undefined || selector.units.includes(actualUnit);+function triggerScopeMatches(scope: ResolvedUnitScope, actualSide: SideId, actualUnit: UnitType): boolean {+ return scope.side === actualSide && unitMaskHas(scope.units, actualUnit);}export function normalizeEngagementType(value: unknown): string | undefined {if (typeof value !== "string") return undefined;Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -29,23 +29,32 @@import {activateEffect,advanceEffectAttackDelay,chancePasses,+ compiledTriggerForSkill,constantActiveEffectValuePct,createSeededRng,effectAttackUseLimit,effectRoundWindow,hasAttackDurationConstraint,isEffectAttackReady,oppositeSide,- parseTriggerSelector,- sideForTriggerRelation,skillMatchesTrigger,sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { createEffectIndex, damageEffectsForJob, expireEffectIndex, indexEffect, isRuntimeIndexableEffect, type EffectIndex } from "./effectIndex";+import {+ cloneEffectIndex,+ createEffectIndex,+ damageEffectsForJob,+ damageJobShapeSlot,+ DAMAGE_JOB_SHAPE_SLOTS,+ expireEffectIndex,+ indexEffect,+ isRuntimeIndexableEffect,+ type EffectIndex+} from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -61,9 +70,9 @@activateEffectsByRound: Array<ActiveEffect[] | undefined>;expireEffectsByRound: Array<ActiveEffect[] | undefined>;// Per-job scratch: effects that affected the job being calculated; drained by// chargeUsedEffects (uses += 1 each) after every job in every mode.- usedEffects: Set<ActiveEffect>;+ usedEffects: ActiveEffect[];staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;skills: RuntimeSkills;@@ -83,8 +92,9 @@roundStart: ResolvedSkill[];roundStartGlobal: ResolvedSkill[];roundStartPerUnit: ResolvedSkill[];attackDeclared: ResolvedSkill[];+ attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;}interface ExtraAttackEffectGroup {selected: ActiveEffect;@@ -220,10 +230,10 @@}// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage// profile. Static-phase effects never enter the per-job index.-function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));+function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number, collectSkillReports = true): Runtime {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), collectSkillReports);const setupEffects = [...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),...addInputPassiveEffects(runtime, input.defender.passive, "defender")@@ -232,36 +242,54 @@runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}-// 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 {+// Clone a prepared template's mutable per-run effect graph. The template already owns the+// correct index topology and round schedules, so remap those references to the effect clones+// instead of classifying and scheduling every effect again. Skills and the immutable static+// profile are shared by reference.+function cloneRuntime(template: Runtime, rng: Rng, collectSkillReports = true): Runtime {+ const effectClones = new Map<ActiveEffect, ActiveEffect>();+ const preparedEffects = template.preparedEffects.map((effect) => {+ const clone = { ...effect };+ effectClones.set(effect, clone);+ return clone;+ });+ const cloneEffect = (effect: ActiveEffect): ActiveEffect => {+ const clone = effectClones.get(effect);+ if (!clone) throw new Error(`prepared effect ${effect.id} is missing from the runtime template`);+ return clone;+ };+const runtime: Runtime = {- effectIndex: createEffectIndex(),- preparedEffects: [],- activateEffectsByRound: [],- expireEffectsByRound: [],- usedEffects: new Set(),+ effectIndex: cloneEffectIndex(template.effectIndex, cloneEffect),+ preparedEffects,+ activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),+ expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),+ usedEffects: [],staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,skills: template.skills,- skillReports: cloneSkillReports(template.skillReports),+ skillReports: collectSkillReports ? cloneSkillReports(template.skillReports) : { attacker: new Map(), defender: new Map() },effectActivationCounts: { ...template.effectActivationCounts },extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },attackControlCounts: { ...template.attackControlCounts },counters: {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 cloneEffectSchedule(+ schedule: Array<ActiveEffect[] | undefined>,+ cloneEffect: (effect: ActiveEffect) => ActiveEffect+): Array<ActiveEffect[] | undefined> {+ return schedule.map((effects) => effects?.map(cloneEffect));+}+function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {const cloneSide = (side: Map<string, SkillReportEntry>): Map<string, SkillReportEntry> => {const out = new Map<string, SkillReportEntry>();for (const [key, entry] of side) out.set(key, { ...entry, unsupportedEffects: [...entry.unsupportedEffects] });@@ -296,20 +324,21 @@options: SimulationOptions,prepared?: CompiledBattle,loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }): BattleRun {+ const collectSkillReports = (options.mode ?? "standard") !== "fast";if (prepared?.template) {const fighters: Record<SideId, ResolvedFighter> = {attacker: cloneFighterForRun(prepared.fighters.attacker),defender: cloneFighterForRun(prepared.fighters.defender)};- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"), collectSkillReports);return runLoop(input, fighters, runtime, options, loopOptions);}const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default");+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", collectSkillReports);return runLoop(input, fighters, runtime, options, loopOptions);}function runLoop(@@ -325,8 +354,17 @@const useEffectsOnCancel = {dodge: options.useEffectsOnDodge ?? true,no_attack: options.useEffectsOnNoAttack ?? true};+ const damageJobOptions = {+ trace: recorder.capturesTrace,+ recordAppliedEffects: recorder.capturesAppliedEffects,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects+ };let rounds = 0;let score = 0;for (let round = 1; round <= maxRounds; round += 1) {@@ -348,9 +386,12 @@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 matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[+ damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)+ ] ?? [];+ triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, intent);const job = normalJob(intent, roundStartTroops);declaredNormalJobs.push({ intent, job });}@@ -386,18 +427,10 @@});continue;}- allJobs.push(job);- const normalResult = calculateDamageJob(job, fighters, [], {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,- usedEffects: runtime.usedEffects- });+ if (recorder.capturesTrace) allJobs.push(job);+ const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}@@ -414,18 +447,10 @@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, [], {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,- usedEffects: runtime.usedEffects- });+ if (recorder.capturesTrace) allJobs.push(extraJob);+ const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");processedExtraJobIds.add(extraJob.id);if (extraJob.sourceEffectId) {@@ -511,9 +536,10 @@for (const skill of runtime.skills.roundStartPerUnit) {const side = roundTriggerSourceSide(skill);const defenderSide = roundTriggerTargetSide(skill, side);if (!skillMatchesTrigger(skill, "round_start", round)) continue;- const report = runtime.skillReports[skill.side].get(reportKey(skill));+ const reports = runtime.skillReports[skill.side];+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));if (report) report.triggersSeen += 1;if (!chancePasses(skill, runtime.rng)) continue;if (report) report.skillActivations += 1;let orderIndex = 0;@@ -539,22 +565,18 @@return skill.trigger.type === "turn" && skill.trigger.source !== undefined;}function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {- const selector = parseTriggerSelector(skill.trigger.source, "self");- const side = sideForTriggerRelation(skill.side, selector.relation);- const living = UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0);- return selector.units === undefined ? living : living.filter((unit) => selector.units?.includes(unit));+ const source = compiledTriggerForSkill(skill).source;+ return UNIT_TYPES.filter((unit) => (roundStartTroops[source.side][unit] ?? 0) > 0 && unitMaskHas(source.units, unit));}function roundTriggerSourceSide(skill: ResolvedSkill): SideId {- const selector = parseTriggerSelector(skill.trigger.source, "self");- return sideForTriggerRelation(skill.side, selector.relation);+ return compiledTriggerForSkill(skill).source.side;}function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {- const selector = parseTriggerSelector(skill.trigger.target, "enemy");- const targetSide = sideForTriggerRelation(skill.side, selector.relation);+ const targetSide = compiledTriggerForSkill(skill).target.side;return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;}function syntheticRoundIntent(@@ -599,12 +621,12 @@const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {+function createRuntime(fighters: ResolvedFighter[], rng: Rng, collectSkillReports = true): Runtime {const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };const skills = buildRuntimeSkills(fighters);- for (const fighter of fighters) {+ for (const fighter of collectSkillReports ? fighters : []) {for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {reports[fighter.side].set(reportKey(skill), {sourceKind: skill.sourceKind,heroName: skill.heroName,@@ -624,9 +646,9 @@effectIndex: createEffectIndex(),preparedEffects: [],activateEffectsByRound: [],expireEffectsByRound: [],- usedEffects: new Set(),+ usedEffects: [],staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,skills,@@ -642,18 +664,32 @@}function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);+ for (const skill of all) compiledTriggerForSkill(skill);const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");const roundStart = all.filter((skill) => skill.trigger.type === "turn");const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");+ const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });+ for (const skill of attackDeclared) {+ const trigger = compiledTriggerForSkill(skill);+ for (const attackerUnit of unitsFromMask(trigger.source.units)) {+ for (const defenderUnit of unitsFromMask(trigger.target.units)) {+ const slot = damageJobShapeSlot("normal", trigger.source.side, attackerUnit, trigger.target.side, defenderUnit);+ const skills = attackDeclaredByJobShape[slot];+ if (skills) skills.push(skill);+ else attackDeclaredByJobShape[slot] = [skill];+ }+ }+ }return {all,battleStart,roundStart,roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),- attackDeclared+ attackDeclared,+ attackDeclaredByJobShape};}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {@@ -716,8 +752,9 @@value: valuePct},ownerSide: side,kind: "modifier",+ bucketIndex: -1,initialValuePct: valuePct,getCurrentValuePct: constantActiveEffectValuePct,appliesTo: { side, units: ALL_UNIT_MASK },appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },@@ -744,9 +781,10 @@): ActiveEffect[] {const activated: ActiveEffect[] = [];for (const skill of skills) {if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;- const report = runtime.skillReports[skill.side].get(reportKey(skill));+ const reports = runtime.skillReports[skill.side];+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));if (report) report.triggersSeen += 1;if (!chancePasses(skill, runtime.rng)) continue;if (report) report.skillActivations += 1;for (const effectIntent of skill.effects) {@@ -1107,11 +1145,11 @@// 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;+ if (runtime.usedEffects.length === 0) return;for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);- runtime.usedEffects.clear();+ runtime.usedEffects.length = 0;}function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {effect.uses += 1;@@ -1135,12 +1173,12 @@const used = runtime.usedEffects;for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {if (effect.expired) continue;if (!advanceEffectAttackDelay(effect)) continue;- if (hasAttackDurationConstraint(effect)) used.add(effect);+ if (hasAttackDurationConstraint(effect)) used.push(effect);}- for (const effect of controlEffects) used.add(effect);- used.add(winningControl);+ for (const effect of controlEffects) used.push(effect);+ if (!controlEffects.includes(winningControl)) used.push(winningControl);chargeUsedEffects(runtime);}function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {Index: simulator/src/staticDamageProfile.ts===================================================================--- simulator/src/staticDamageProfile.ts prev run+++ simulator/src/staticDamageProfile.ts this run@@ -10,8 +10,9 @@}export interface StaticDamageProfileEntry {factor: number;+ playerFactorsValid: boolean;buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;}export interface StaticDamageProfile {@@ -129,9 +130,9 @@setPct(buckets, "player.defense", bonuses.defense, [{ effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }]);}- return { factor: 1, buckets };+ return { factor: 1, playerFactorsValid: true, buckets };}function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {const groups = new Map<string, PassiveCandidateGroup>();@@ -176,14 +177,22 @@function recomputeFactors(profile: StaticDamageProfile): void {for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {- profile.offense[side][unit].factor = offenseFactor(profile.offense[side][unit]);- profile.defense[side][unit].factor = defenseFactor(profile.defense[side][unit]);+ const offense = profile.offense[side][unit];+ const defense = profile.defense[side][unit];+ offense.factor = offenseFactor(offense);+ defense.factor = defenseFactor(defense);+ offense.playerFactorsValid = playerPctFactorValid(offense, "player.attack") && playerPctFactorValid(offense, "player.lethality");+ defense.playerFactorsValid = playerPctFactorValid(defense, "player.health") && playerPctFactorValid(defense, "player.defense");}}}+function playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {+ return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;+}+interface StaticFactorTerm {bucket: StaticDamageBucket;valueType: BucketValueType;placement: BucketPlacement;Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -203,8 +203,16 @@troopType?: UnitType;level: number;trigger: TriggerDefinition;effects: EffectIntentDefinition[];+ compiledTrigger?: {+ definition: TriggerDefinition;+ side: SideId;+ level: number;+ source: ResolvedUnitScope;+ target: ResolvedUnitScope;+ probabilityPct: number;+ };}export interface ResolvedHero {name: string;@@ -245,8 +253,11 @@source: EffectSource;intent: EffectIntentDefinition;ownerSide: SideId;kind: ActiveEffectKind;+ // Numeric slot in the runtime damage scratch. Dynamic modifiers receive it+ // when indexed; non-damage effects keep -1.+ bucketIndex: number;initialValuePct: number;getCurrentValuePct(round: number): number;// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",// trigger-relative selectors, or concrete unit selectors; it does not accept "all".
Show raw per-run patches
Run A 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;
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Alonso.json b/simulator/config/hero_definitions/Alonso.json--- a/simulator/config/hero_definitions/Alonso.json+++ b/simulator/config/hero_definitions/Alonso.json@@ -13,8 +13,7 @@40,40,40- ],- "source": "self.any"+ ]},"effects": {"Onslaught/1": {@@ -26,10 +25,6 @@40,50],- "units": {- "applies_to": "trigger",- "applies_vs": "trigger.target"- },"duration": {"turns": {"count": 1diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json--- a/simulator/config/hero_definitions/Gwen.json+++ b/simulator/config/hero_definitions/Gwen.json@@ -113,9 +113,11 @@]},"duration": {- "attacks": {- "count": 1,+ "turns": {"delay": 1+ },+ "attacks": {+ "count": 1}},"trigger_damage_jobs": [diff --git a/simulator/config/hero_definitions/Mia.json b/simulator/config/hero_definitions/Mia.json--- a/simulator/config/hero_definitions/Mia.json+++ b/simulator/config/hero_definitions/Mia.json@@ -30,7 +30,8 @@},"duration": {"turns": {- "count": 1+ "count": 1,+ "delay": 1}},"same_effect_stacking": "max"diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts--- a/simulator/src/classifierDamage.test.ts+++ b/simulator/src/classifierDamage.test.ts@@ -4,8 +4,8 @@import { classifyEffectForJob } from "./classifier";import { calculateDamageJob } from "./damage";import { ATOMIC_BUCKETS } from "./damageBuckets";-import { createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";-import { activateEffect } from "./effects";+import { createEffectIndex, indexEffect } from "./effectIndex";+import { activateEffect, evolvingActiveEffectValuePct } from "./effects";import { buildStaticDamageProfile, STATIC_PASSIVE_BUCKETS } from "./staticDamageProfile";import type { ActiveEffect, DamageJob, ResolvedFighter } from "./types";import { ALL_UNIT_MASK, unitMask } from "./types";@@ -33,12 +33,15 @@intent: { id: "scope/1", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ bucketIndex: -1,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask(["infantry"]) },appliesVs: { side: "defender", units: unitMask(["lancer"]) },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -78,12 +81,15 @@intent: { id: `${type}/1`, type, value: [valuePct] },ownerSide,kind: "modifier",- valuePct,+ bucketIndex: -1,+ initialValuePct: valuePct,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: ownerSide, units: ALL_UNIT_MASK },appliesVs: { side: ownerSide === "attacker" ? "defender" : "attacker", units: ALL_UNIT_MASK },createdRound: 1,startRound: 1,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"};@@ -100,10 +106,9 @@for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);- removeStaticProfileBucketEffects(effectIndex);- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const usedEffects = options.usedEffects ?? [];const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });- return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };+ return { ...result, usedEffectIds: usedEffects.map((usedEffect) => usedEffect.id) };}test("classifier routes up/down effects into neutral atomic buckets", () => {@@ -418,7 +423,7 @@const oneAttackEffect = {...effect("active.hero.attack.up", "attacker", 100),id: "attack-up-active",- duration: { type: "attack" as const, value: 1 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });@@ -432,7 +437,7 @@...effect("active.hero.defense.down", "defender", 30),id: "bad-luck-like",source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },- duration: { type: "round" as const, value: 1 }+ duration: { turns: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });@@ -456,7 +461,7 @@const defenderOutgoingBuff = {...effect("active.hero.lethality.up", "defender", 100),id: "defender-outgoing-buff",- duration: { type: "attack" as const, value: 1 }+ duration: { attacks: { count: 1 } }};const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });@@ -475,7 +480,9 @@value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 15 }},- duration: { type: "attack" as const, value: 10 },+ duration: { attacks: { count: 10 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 2};@@ -499,7 +506,9 @@},createdRound: 0,startRound: 0,- duration: { type: "attack" as const, value: 10 }+ valueEvolution: { type: "pct_decay", step: "turn", amount: 15 },+ getCurrentValuePct: evolvingActiveEffectValuePct,+ duration: { attacks: { count: 10 } }};const firstTurn = calculateIndexedDamageJob({ ...job, round: 1 }, simpleFighters(), [turnDecayAttackUp], { trace: true });@@ -513,7 +522,7 @@const weaker = {...effect("active.hero.lethality.up", "attacker", 50),id: "max-weaker",- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },stackingKey: "same-max-group",sameEffectStacking: "max" as const};@@ -526,7 +535,9 @@value: 100,value_evolution: { type: "pct_decay", step: "attack", value: 50 }},- duration: { type: "attack" as const, value: 3 },+ duration: { attacks: { count: 3 } },+ valueEvolution: { type: "pct_decay", step: "attack", amount: 50 },+ getCurrentValuePct: evolvingActiveEffectValuePct,uses: 1,stackingKey: "same-max-group",sameEffectStacking: "max" as constdiff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts--- a/simulator/src/config.test.ts+++ b/simulator/src/config.test.ts@@ -9,6 +9,15 @@import { loadSimulatorConfigFromDir } from "./config-node";import type { SkillFile } from "./types";+test("Gwen Blastmaster uses a turn delay and a one-attack duration", () => {+ const effect = loadSimulatorConfig().heroDefinitions.Gwen.skills.Blastmaster.effects["Blastmaster/1"];++ assert.deepEqual(effect.duration, {+ turns: { delay: 1 },+ attacks: { count: 1 }+ });+});+test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {const root = writeConfigWithTroopEffect({type: "active.hero.lethality.up",diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -275,11 +275,11 @@const path = `${file}:${skillId}.${effectId}.duration`;const duration = effect.duration as Record<string, unknown>;for (const key of Object.keys(duration)) {- if (key !== "turns" && key !== "rounds" && key !== "attacks") {+ if (key !== "turns" && key !== "attacks") {throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);}}- for (const key of ["turns", "rounds", "attacks"] as const) {+ for (const key of ["turns", "attacks"] as const) {const value = duration[key];if (value === undefined) continue;validateNativeEffectDurationAxis(value, `${path}.${key}`);diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -5,15 +5,13 @@DamageEquationTrace,DamageJob,ResolvedFighter,- SameEffectStacking,SideId,UnitType} from "./types";import { UNIT_TYPES } from "./types";-import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";-import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";+import { advanceEffectAttackDelay, sourceLabel } from "./effects";+import { damageEffectsForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,type StaticDamageBucket,@@ -33,15 +31,9 @@appliesTo?: DamageJob["kind"];}-interface BucketCandidate {- effect: ActiveEffect;- bucket: AtomicBucket;- valuePct: number;-}--interface MaxBucketCandidateGroup {- selected: BucketCandidate;- candidates: BucketCandidate[];+interface MaxBucketEffectGroup {+ selected: ActiveEffect;+ effects: ActiveEffect[];}interface DamageExpressionResult {@@ -67,13 +59,25 @@const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};-const TROOPS_COUNT_TERM = factorTerm("troops.count");-const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");+const TROOPS_COUNT_INDEX = BUCKET_IDS["troops.count"];+const SOURCE_EXTRA_SKILL_INDEX = BUCKET_IDS["source.extraSkill"];const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");const DEFAULT_DENOMINATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "denominator");const PROFILED_NUMERATOR_TERMS = DEFAULT_NUMERATOR_TERMS.filter((term) => term.bucketName !== "troops.count" && term.bucketName !== "source.extraSkill");const PROFILED_DENOMINATOR_TERMS = DEFAULT_DENOMINATOR_TERMS;+const factorSlots = (terms: DamageFactorTerm[], kind: DamageJob["kind"]): Int32Array =>+ Int32Array.from(terms.filter((term) => !term.appliesTo || term.appliesTo === kind).map((term) => term.bucket));+const DEFAULT_NUMERATOR_SLOTS = { normal: factorSlots(DEFAULT_NUMERATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_NUMERATOR_TERMS, "skill") };+const DEFAULT_DENOMINATOR_SLOTS = { normal: factorSlots(DEFAULT_DENOMINATOR_TERMS, "normal"), skill: factorSlots(DEFAULT_DENOMINATOR_TERMS, "skill") };+const PROFILED_NUMERATOR_SLOTS = { normal: factorSlots(PROFILED_NUMERATOR_TERMS, "normal"), skill: factorSlots(PROFILED_NUMERATOR_TERMS, "skill") };+const PROFILED_DENOMINATOR_SLOTS = DEFAULT_DENOMINATOR_SLOTS;+const BUCKET_UPDATE_BY_INDEX = Uint8Array.from(+ ATOMIC_BUCKETS.map((bucket) => {+ const update = BUCKET_DEFINITIONS[bucket].update;+ return update === "assign_factor" ? 0 : update === "multiply_pct_factor" ? 1 : 2;+ })+);export class DamageAggregationError extends Error {readonly groupId: string;@@ -106,7 +110,7 @@job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }+ options: { trace?: boolean; recordAppliedEffects?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: ActiveEffect[] }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");// The damage math is one path; `trace` only decides whether we also capture the (expensive)@@ -123,47 +127,27 @@const armyTerm = Math.ceil(Math.sqrt(Math.max(0, attackerTroops)) * Math.sqrt(minInitialArmy));const needsTraceBuckets = traceEnabled;const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);- applyBucketValue(buckets, "troops.count", armyTerm);- applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);+ buckets.factors[TROOPS_COUNT_INDEX] = Math.max(0, armyTerm);+ buckets.factors[SOURCE_EXTRA_SKILL_INDEX] = Math.max(0, job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);const appliedEffects: DamageEquationTrace["appliedEffects"] = [];const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];- const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();- const candidates: BucketCandidate[] = [];- const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;- for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- if (!isEffectActive(candidate.effect, job.round)) continue;- handledCandidateEffectIds?.add(candidate.effect.id);- candidates.push({- effect: candidate.effect,- bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round)- });- }-- if (traceEnabled) {- for (const effect of options.effectIndex.all) {- if (handledCandidateEffectIds?.has(effect.id)) continue;- if (!isEffectActive(effect, job.round)) {- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "not_active_this_round" });- continue;- }- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "bucket" && classification.bucket) {- throw new Error(`Effect index missed bucket candidate ${effect.id} for damage job ${job.id}`);- }- rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });- }- }+ const usedEffects = options.usedEffects ?? [];+ applyBucketEffects(+ damageEffectsForJob(options.effectIndex, job),+ job.round,+ buckets,+ detail,+ recordAppliedEffects,+ appliedEffects,+ rejectedEffects,+ usedEffects+ );- applyBucketCandidates(candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (recordAppliedEffects) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);- const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];- const traceBuckets = needsTraceBuckets ? toTraceBuckets(buckets, staticTraceEntries) : undefined;- const expressionDetail = traceEnabled ? "full" : "fast";- const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, expressionDetail, staticProfile);+ const { rawDamage, aggregationGroups } = evaluateDefaultDamageExpression(job, buckets, detail, staticProfile);const uncappedKills = Math.max(0, rawDamage);const kills = options.capToDefenderTroops === false ? uncappedKills : Math.min(defenderTroops, uncappedKills);const trace = traceEnabled@@ -173,7 +157,10 @@defender: { ...job.roundStartTroops.defender }},armyTerm,- atomicBuckets: traceBuckets ?? toTraceBuckets(buckets, staticTraceEntries),+ atomicBuckets: toTraceBuckets(buckets, [+ staticProfile.offense[job.attackerSide][job.attackerUnit],+ staticProfile.defense[job.defenderSide][job.defenderUnit]+ ]),aggregationGroups,appliedEffects,rejectedEffects,@@ -189,69 +176,79 @@};}-function applyBucketCandidates(- candidates: BucketCandidate[],+function applyBucketEffects(+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {- let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;- for (const candidate of candidates) {- if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {+ let maxGroups: Map<string, MaxBucketEffectGroup> | undefined;+ for (const effect of effects) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (effect.sameEffectStacking === "max" && effect.stackingKey) {maxGroups ??= new Map();- const key = `${candidate.bucket}:${candidate.effect.stackingKey}`;+ const key = `${effect.intent.type}:${effect.stackingKey}`;const group = maxGroups.get(key);if (group) {- group.candidates.push(candidate);- if (candidate.valuePct > group.selected.valuePct) group.selected = candidate;+ group.effects.push(effect);+ if (effect.getCurrentValuePct(round) > group.selected.getCurrentValuePct(round)) group.selected = effect;} else {- maxGroups.set(key, { selected: candidate, candidates: [candidate] });+ maxGroups.set(key, { selected: effect, effects: [effect] });}} else {- applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffect(effect, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);+ applyBucketEffectGroup(group.selected, group.effects, round, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the// applied percentage. Shared by the single-candidate and max-group paths.function applySelectedBucket(- selected: BucketCandidate,+ selected: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"]): number {- const appliedValuePct = applyBucketValue(- buckets,- selected.bucket,- selected.valuePct,- detail === "full" ? selected.effect.source.effectId ?? selected.effect.id : "",- detail === "full" ? sourceLabel(selected.effect) : "",- detail === "full" ? selected.effect.ownerSide : undefined,- selected.bucket,- selected.effect.stackingKey,- selected.effect.sameEffectStacking- );+ const appliedValuePct = selected.getCurrentValuePct(round);+ const bucketIndex = selected.bucketIndex;+ const update = BUCKET_UPDATE_BY_INDEX[bucketIndex];+ if (update === 0) buckets.factors[bucketIndex] = Math.max(0, appliedValuePct);+ else if (update === 1) buckets.factors[bucketIndex] *= 1 + appliedValuePct / 100;+ else buckets.factors[bucketIndex] += appliedValuePct / 100;+ if (detail === "full") {+ buckets.contributors?.[bucketIndex].push({+ effectId: selected.source.effectId ?? selected.id,+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ valuePct: appliedValuePct,+ bucket: selected.intent.type,+ stackingKey: selected.stackingKey,+ sameEffectStacking: selected.sameEffectStacking+ });+ }if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",- activeEffectId: selected.effect.id,- effectId: selected.effect.source.effectId ?? selected.effect.id,- bucket: selected.bucket,+ activeEffectId: selected.id,+ effectId: selected.source.effectId ?? selected.id,+ bucket: selected.intent.type,valuePct: appliedValuePct,- source: sourceLabel(selected.effect),- sourceSide: selected.effect.ownerSide,- sameEffectStacking: selected.effect.sameEffectStacking+ source: sourceLabel(selected),+ sourceSide: selected.ownerSide,+ sameEffectStacking: selected.sameEffectStacking};- if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;+ if (selected.stackingKey !== undefined) appliedEffect.stackingKey = selected.stackingKey;appliedEffects.push(appliedEffect);}return appliedValuePct;@@ -259,45 +256,47 @@// Lone-candidate fast path (the common case): no max-stacking group, so no temporary [candidate]// array and no suppressed-sibling bookkeeping.-function applyBucketCandidate(- candidate: BucketCandidate,+function applyBucketEffect(+ effect: ActiveEffect,+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(effect, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {- usedEffects.add(candidate.effect);+ usedEffects.push(effect);} else if (detail === "full") {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}-function applyBucketCandidateGroup(- selected: BucketCandidate,- candidates: BucketCandidate[],+function applyBucketEffectGroup(+ selected: ActiveEffect,+ effects: ActiveEffect[],+ round: number,buckets: NumericDamageBuckets,detail: DamageDetail,recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- usedEffects: Set<ActiveEffect>+ usedEffects: ActiveEffect[]): void {- const appliedValuePct = applySelectedBucket(selected, buckets, detail, recordAppliedEffects, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, round, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {// The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.- for (const candidate of candidates) {- usedEffects.add(candidate.effect);- if (detail === "full" && candidate !== selected) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });+ for (const effect of effects) {+ usedEffects.push(effect);+ if (detail === "full" && effect !== selected) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_suppressed" });}}} else if (detail === "full") {- for (const candidate of candidates) {- rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });+ for (const effect of effects) {+ rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: "same_effect_max_superseded" });}}}@@ -331,26 +330,6 @@return buckets;}-function applyBucketValue(- buckets: NumericDamageBuckets,- bucketName: AtomicBucket,- value: number,- effectId = "",- source = "",- sourceSide: SideId | undefined = undefined,- traceBucketName = bucketName,- stackingKey?: string,- sameEffectStacking: SameEffectStacking = "add"-): number {- const index = BUCKET_IDS[bucketName];- const definition = BUCKET_DEFINITIONS[bucketName];- if (definition.update === "assign_factor") buckets.factors[index] = Math.max(0, value);- else if (definition.update === "multiply_pct_factor") buckets.factors[index] *= 1 + value / 100;- else buckets.factors[index] += value / 100;- if (effectId) buckets.contributors?.[index].push({ effectId, source, sourceSide, valuePct: value, bucket: traceBucketName, stackingKey, sameEffectStacking });- return value;-}-function appendStaticProfileAppliedEffects(appliedEffects: DamageEquationTrace["appliedEffects"], entry: StaticDamageProfileEntry): void {for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {if (!bucket.startsWith("passive.") || !term) continue;@@ -372,10 +351,11 @@function evaluateDefaultDamageExpression(job: DamageJob, buckets: NumericDamageBuckets, detail: DamageDetail, staticProfile?: StaticDamageProfile): DamageExpressionResult {if (staticProfile) return evaluateProfiledDamageExpression(job, buckets, detail, staticProfile);+ const factors = buckets.factors;let numerator = 1;- for (const term of DEFAULT_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ for (const slot of DEFAULT_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];let denominator = 1;- for (const term of DEFAULT_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ for (const slot of DEFAULT_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];return {rawDamage: numerator / denominator,aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets) : EMPTY_AGGREGATION_GROUPS@@ -388,15 +368,18 @@detail: DamageDetail,staticProfile: StaticDamageProfile): DamageExpressionResult {- validateProfiledStaticFactors(job, staticProfile);+ const offense = staticProfile.offense[job.attackerSide][job.attackerUnit];+ const defense = staticProfile.defense[job.defenderSide][job.defenderUnit];+ if (!offense.playerFactorsValid || !defense.playerFactorsValid) validateProfiledStaticFactors(job, staticProfile);+ const factors = buckets.factors;let numerator =- valueForTerm(TROOPS_COUNT_TERM, job, buckets) *- valueForTerm(SOURCE_EXTRA_SKILL_TERM, job, buckets) *- staticProfile.offense[job.attackerSide][job.attackerUnit].factor *- staticProfile.defense[job.defenderSide][job.defenderUnit].factor;- for (const term of PROFILED_NUMERATOR_TERMS) numerator *= valueForTerm(term, job, buckets);+ factors[TROOPS_COUNT_INDEX] *+ factors[SOURCE_EXTRA_SKILL_INDEX] *+ offense.factor *+ defense.factor;+ for (const slot of PROFILED_NUMERATOR_SLOTS[job.kind]) numerator *= factors[slot];let denominator = 100;- for (const term of PROFILED_DENOMINATOR_TERMS) denominator *= valueForTerm(term, job, buckets);+ for (const slot of PROFILED_DENOMINATOR_SLOTS[job.kind]) denominator *= factors[slot];return {rawDamage: numerator / denominator,aggregationGroups: detail === "full" ? buildAggregationGroups(job, buckets, staticProfile) : EMPTY_AGGREGATION_GROUPS@@ -425,11 +408,6 @@});}-function valueForTerm(term: DamageFactorTerm, job: DamageJob, buckets: NumericDamageBuckets): number {- if (term.appliesTo && term.appliesTo !== job.kind) return 1;- return buckets.factors[term.bucket];-}-function buildAggregationGroups(job: DamageJob, buckets: NumericDamageBuckets, staticProfile?: StaticDamageProfile): Record<string, DamageAggregationGroupTrace> {const aggregationGroups: Record<string, DamageAggregationGroupTrace> = {};if (staticProfile) addStaticAggregationGroups(aggregationGroups, job, staticProfile);diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts--- a/simulator/src/effectIndex.test.ts+++ b/simulator/src/effectIndex.test.ts@@ -1,7 +1,7 @@import assert from "node:assert/strict";import { test } from "node:test";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, removeStaticProfileBucketEffects } from "./effectIndex";+import { cloneEffectIndex, createEffectIndex, damageEffectsForJob, indexEffect } from "./effectIndex";import { unitMask } from "./types";import type { ActiveEffect, DamageJob } from "./types";@@ -13,12 +13,15 @@intent: { id: "boost", type: "active.hero.lethality.up", value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ bucketIndex: -1,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"@@ -40,20 +43,36 @@defenderUnit: "lancer"};- assert.deepEqual(bucketCandidatesForJob(index, job), [{ effect, bucket: "active.hero.lethality.up" }]);+ assert.deepEqual(damageEffectsForJob(index, job), [effect]);});-test("effect index can remove static-profile bucket effects after the static damage profile is built", () => {+test("effect index excludes static-profile bucket effects", () => {const index = createEffectIndex();const passive = effect("passive.attack.up");const active = effect("active.hero.lethality.up");indexEffect(index, passive);indexEffect(index, active);- removeStaticProfileBucketEffects(index);+ assert.deepEqual(damageEffectsForJob(index, job()), [active]);+});++test("cloned effect index remaps shared effects while owning its bucket arrays", () => {+ const index = createEffectIndex();+ const original = effect("active.hero.lethality.up");+ const replacement = { ...original };+ indexEffect(index, original);++ const clone = cloneEffectIndex(index, (candidate) => {+ assert.equal(candidate, original);+ return replacement;+ });+ const originalCandidates = damageEffectsForJob(index, job());+ const clonedCandidates = damageEffectsForJob(clone, job());- assert.deepEqual(bucketCandidatesForJob(index, job()), [{ effect: active, bucket: "active.hero.lethality.up" }]);- assert.deepEqual(index.all, [active]);+ assert.notEqual(clonedCandidates, originalCandidates);+ assert.deepEqual(clonedCandidates, [replacement]);+ clonedCandidates.push(effect("active.hero.attack.up"));+ assert.deepEqual(originalCandidates, [original]);});function effect(type: string): ActiveEffect {@@ -63,12 +82,15 @@intent: { id: type, type, value: 25 },ownerSide: "attacker",kind: "modifier",- valuePct: 25,+ bucketIndex: -1,+ initialValuePct: 25,+ getCurrentValuePct() { return this.initialValuePct; },appliesTo: { side: "attacker", units: unitMask("infantry") },appliesVs: { side: "defender", units: unitMask("lancer") },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,stackingKey: "stack",sameEffectStacking: "add"diff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts--- a/simulator/src/effectIndex.ts+++ b/simulator/src/effectIndex.ts@@ -1,26 +1,19 @@import type { ActiveEffect, DamageJob, DamageKind, SideId, UnitType } from "./types";import { unitsFromMask } from "./types";-import { bucketDefinition, type AtomicBucket } from "./damageBuckets";-import { isStaticProfileBucket } from "./staticDamageProfile";--export interface IndexedBucketEffect {- effect: ActiveEffect;- bucket: AtomicBucket;-}+import { ATOMIC_BUCKETS, bucketDefinition, type AtomicBucket } from "./damageBuckets";export interface EffectIndex {- all: ActiveEffect[];- damageByJobShape: Array<IndexedBucketEffect[] | undefined>;+ damageByJobShape: Array<ActiveEffect[] | undefined>;controls: ActiveEffect[];extraAttacks: ActiveEffect[];battleOrder: ActiveEffect[];}-const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+const BUCKET_INDEX = new Map<string, number>(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index]));export function createEffectIndex(): EffectIndex {return {- all: [],damageByJobShape: Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }),controls: [],extraAttacks: [],@@ -28,8 +21,16 @@};}+export function cloneEffectIndex(index: EffectIndex, cloneEffect: (effect: ActiveEffect) => ActiveEffect): EffectIndex {+ return {+ damageByJobShape: index.damageByJobShape.map((effects) => effects?.map(cloneEffect)),+ controls: index.controls.map(cloneEffect),+ extraAttacks: index.extraAttacks.map(cloneEffect),+ battleOrder: index.battleOrder.map(cloneEffect)+ };+}+export function indexEffect(index: EffectIndex, effect: ActiveEffect): void {- index.all.push(effect);if (effect.kind === "control") {index.controls.push(effect);return;@@ -47,58 +48,33 @@// Static-phase buckets (passive.*) are aggregated by the static damage profile, not the// per-job runtime path, so they must not enter the damage job-shape index.if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;+ effect.bucketIndex = BUCKET_INDEX.get(definition.path) ?? -1;- const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];- const appliesToUnits = unitsFromMask(effect.appliesTo.units);- const appliesVsUnits = unitsFromMask(effect.appliesVs.units);- for (const jobKind of jobKinds) {- for (const appliesToUnit of appliesToUnits) {- for (const appliesVsUnit of appliesVsUnits) {- const slot =- definition.role === "attacker"- ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)- : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);- const arr = index.damageByJobShape[slot];- const candidate = { effect, bucket: definition.path };- if (arr) arr.push(candidate);- else index.damageByJobShape[slot] = [candidate];- }- }+ for (const slot of shapeSlotsFor(effect, definition.path)) {+ const arr = index.damageByJobShape[slot];+ if (arr) arr.push(effect);+ else index.damageByJobShape[slot] = [effect];}}-export function pruneEffectIndex(index: EffectIndex, isActive: (effect: ActiveEffect) => boolean): void {- compactEffects(index.all, isActive);- compactEffects(index.controls, isActive);- compactEffects(index.extraAttacks, isActive);- compactEffects(index.battleOrder, isActive);- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- compactCandidates(arr, isActive);- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function isRuntimeIndexableEffect(effect: ActiveEffect): boolean {+ if (effect.kind === "control" || effect.kind === "extra_attack" || effect.kind === "battle_order") return true;+ const definition = bucketDefinition(effect.intent.type);+ return definition !== undefined && definition.valueType === "pct" && definition.phase !== "static";}-export function removeStaticProfileBucketEffects(index: EffectIndex): void {- compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));- for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const arr = index.damageByJobShape[slot];- if (!arr) continue;- let write = 0;- for (let read = 0; read < arr.length; read += 1) {- if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }- }- arr.length = write;- if (arr.length === 0) index.damageByJobShape[slot] = undefined;- }+export function expireEffectIndex(index: EffectIndex, effect: ActiveEffect): void {+ effect.expired = true;+ if (effect.kind === "control") removeStable(index.controls, effect);+ else if (effect.kind === "extra_attack") removeStable(index.extraAttacks, effect);+ else if (effect.kind === "battle_order") removeStable(index.battleOrder, effect);}-export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {+export function damageEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];}-function damageJobShapeSlot(+export function damageJobShapeSlot(jobKind: DamageKind,attackerSide: SideId,attackerUnit: UnitType,@@ -108,6 +84,36 @@return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);}+const SHAPE_SLOTS_CACHE = new Map<number, Uint8Array>();+function shapeSlotsFor(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const key =+ ((((BUCKET_INDEX.get(bucket) ?? 0) * 2 + sideIndex(effect.appliesTo.side)) * 8 + (effect.appliesTo.units & 7)) * 2 + sideIndex(effect.appliesVs.side)) * 8 ++ (effect.appliesVs.units & 7);+ const cached = SHAPE_SLOTS_CACHE.get(key);+ if (cached) return cached;+ const slots = buildShapeSlots(effect, bucket);+ SHAPE_SLOTS_CACHE.set(key, slots);+ return slots;+}++function buildShapeSlots(effect: ActiveEffect, bucket: AtomicBucket): Uint8Array {+ const definition = bucketDefinition(bucket)!;+ const slots: number[] = [];+ const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];+ for (const jobKind of jobKinds) {+ for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {+ for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {+ slots.push(+ definition.role === "attacker"+ ? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit)+ : damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit)+ );+ }+ }+ }+ return Uint8Array.from(slots);+}+function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {@@ -116,29 +122,7 @@return 2;}-function isStaticProfileEffect(effect: ActiveEffect): boolean {- const definition = bucketDefinition(effect.intent.type);- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));-}--function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < effects.length; read += 1) {- const effect = effects[read];- if (!isActive(effect)) continue;- effects[write] = effect;- write += 1;- }- effects.length = write;-}--function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {- let write = 0;- for (let read = 0; read < candidates.length; read += 1) {- const candidate = candidates[read];- if (!isActive(candidate.effect)) continue;- candidates[write] = candidate;- write += 1;- }- candidates.length = write;+function removeStable(effects: ActiveEffect[], effect: ActiveEffect): void {+ const index = effects.indexOf(effect);+ if (index >= 0) effects.splice(index, 1);}diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -4,24 +4,38 @@AttackIntent,EffectDuration,EffectIntentDefinition,+ EvolvingActiveEffect,ResolvedSkill,ResolvedUnitScope,SameEffectStacking,SideId,UnitType} from "./types";-import { ALL_UNIT_MASK, unitMask } from "./types";+import { ALL_UNIT_MASK, unitMask, unitMaskHas } from "./types";import { normalizeUnitType } from "./normalize";export type Rng = () => number;-type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];--interface CompiledTriggerSelectors {- source: ParsedTriggerSelector;- target: ParsedTriggerSelector;+interface CompiledActivation {+ idPrefix: string;+ source: ActiveEffect["source"];+ ownerSide: SideId;+ kind: ActiveEffectKind;+ initialValuePct: number;+ getCurrentValuePct: ActiveEffect["getCurrentValuePct"];+ valueEvolution?: EvolvingActiveEffect["valueEvolution"];+ triggerDamageJobs: ActiveEffect["triggerDamageJobs"];+ duration: EffectDuration;+ turnDelay: number;+ attackDelay: number;+ stackingKey: string;+ sameEffectStacking: SameEffectStacking;+ staticAppliesTo: ResolvedUnitScope;+ staticAppliesVs: ResolvedUnitScope;+ intentScoped: boolean;}-const TRIGGER_SELECTOR_CACHE = new WeakMap<ResolvedSkill, CompiledTriggerSelectors>();+const ACTIVATION_CACHE = new WeakMap<EffectIntentDefinition, CompiledActivation>();+const INTENT_SCOPED_VALUES = new Set(["trigger.source", "trigger", "trigger.target", "target"]);export function oppositeSide(side: SideId): SideId {return side === "attacker" ? "defender" : "attacker";@@ -40,32 +54,47 @@if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every, trigger.first)) return false;if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every, trigger.first)) return false;if (!intent) return true;- const selectors = compiledTriggerSelectors(skill);+ const selectors = compiledTriggerForSkill(skill);return (- triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&- triggerSelectorMatches(selectors.target, skill.side, intent.defenderSide, intent.defenderUnit)+ triggerScopeMatches(selectors.source, intent.attackerSide, intent.attackerUnit) &&+ triggerScopeMatches(selectors.target, intent.defenderSide, intent.defenderUnit));}-function compiledTriggerSelectors(skill: ResolvedSkill): CompiledTriggerSelectors {- const cached = TRIGGER_SELECTOR_CACHE.get(skill);- if (cached) return cached;+export function compiledTriggerForSkill(skill: ResolvedSkill): NonNullable<ResolvedSkill["compiledTrigger"]> {+ const cached = skill.compiledTrigger;+ if (cached && cached.definition === skill.trigger && cached.side === skill.side && cached.level === skill.level) return cached;const compiled = {- source: parseTriggerSelector(skill.trigger.source, "self"),- target: parseTriggerSelector(skill.trigger.target, "enemy")+ definition: skill.trigger,+ side: skill.side,+ level: skill.level,+ source: compileTriggerScope(skill, skill.trigger.source, "self"),+ target: compileTriggerScope(skill, skill.trigger.target, "enemy"),+ probabilityPct: resolvedProbabilityPct(skill)};- TRIGGER_SELECTOR_CACHE.set(skill, compiled);+ skill.compiledTrigger = compiled;return compiled;}-export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+function compileTriggerScope(skill: ResolvedSkill, value: unknown, defaultRelation: TriggerSelectorRelation): ResolvedUnitScope {+ const selector = parseTriggerSelector(value, defaultRelation);+ return {+ side: sideForTriggerRelation(skill.side, selector.relation),+ units: selector.units ? unitMask(selector.units) : ALL_UNIT_MASK+ };+}++function resolvedProbabilityPct(skill: ResolvedSkill): number {const probability = skill.trigger.probability;- if (probability === undefined) return true;- const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);- if (!Number.isFinite(value) || value <= 0) return false;+ const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability ?? 100);+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;+}++export function chancePasses(skill: ResolvedSkill, rng: Rng): boolean {+ const value = compiledTriggerForSkill(skill).probabilityPct;+ if (value <= 0) return false;if (value >= 100) return true;- const threshold = value / 100;- return rng() < threshold;+ return rng() < value / 100;}export function createSeededRng(seed: string | number = "simulator-default"): Rng {@@ -82,20 +111,22 @@return Math.floor((previous - first) / frequency) < Math.floor((current - first) / frequency);}-export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+function compiledActivation(skill: ResolvedSkill, intent: EffectIntentDefinition): CompiledActivation {+ const cached = ACTIVATION_CACHE.get(intent);+ if (cached) return cached;const units = intent.units ?? {};const ownerSide = skill.side;- const appliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", attackIntent, ownerSide);- const appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, ownerSide);+ const staticAppliesTo = resolveUnitScope(units.applies_to, ownerSide, "applies_to", undefined, ownerSide);+ const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);const duration = normalizeDuration(intent.duration);- const delay = duration.delay ?? 0;const effectKind = kindForIntent(intent);if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);}const sourceKey = skillActivationSourceKey(skill);- return {- id: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r${round}:${attackIntent?.id ?? "global"}`,+ const evolution = intent.value_evolution;+ const compiled: CompiledActivation = {+ idPrefix: `${skill.side}:${skill.sourceKind}:${sourceKey}:${skill.id}:${intent.id}:r`,source: {kind: skill.sourceKind,side: skill.side,@@ -106,20 +137,65 @@skillName: skill.name,effectId: intent.id},- intent,ownerSide,kind: effectKind,- valuePct: typeof intent.value === "number" ? intent.value : undefined,+ initialValuePct: finiteNumberOrZero(intent.value),+ getCurrentValuePct: evolution ? evolvingActiveEffectValuePct : constantActiveEffectValuePct,+ valueEvolution: evolution+ ? {+ type: evolution.type,+ step: evolution.step,+ amount: finiteNumberOrZero(evolution.value)+ }+ : undefined,+ triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ duration,+ turnDelay: Math.max(0, duration.turns?.delay ?? 0),+ attackDelay: duration.attacks?.delay ?? 0,+ stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,+ sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking),+ staticAppliesTo,+ staticAppliesVs,+ intentScoped:+ (typeof units.applies_to === "string" && INTENT_SCOPED_VALUES.has(units.applies_to)) ||+ (typeof units.applies_vs === "string" && INTENT_SCOPED_VALUES.has(units.applies_vs))+ };+ ACTIVATION_CACHE.set(intent, compiled);+ return compiled;+}++export function activateEffect(skill: ResolvedSkill, intent: EffectIntentDefinition, round: number, attackIntent?: AttackIntent): ActiveEffect {+ const compiled = compiledActivation(skill, intent);+ let appliesTo = compiled.staticAppliesTo;+ let appliesVs = compiled.staticAppliesVs;+ if (compiled.intentScoped && attackIntent) {+ const units = intent.units ?? {};+ appliesTo = resolveUnitScope(units.applies_to, compiled.ownerSide, "applies_to", attackIntent, compiled.ownerSide);+ appliesVs = resolveUnitScope(units.applies_vs, oppositeSide(appliesTo.side), "applies_vs", attackIntent, compiled.ownerSide);+ }+ const effect: ActiveEffect = {+ id: compiled.idPrefix + round + ":" + (attackIntent?.id ?? "global"),+ expired: false,+ source: compiled.source,+ intent,+ ownerSide: compiled.ownerSide,+ kind: compiled.kind,+ bucketIndex: -1,+ initialValuePct: compiled.initialValuePct,+ getCurrentValuePct: compiled.getCurrentValuePct,appliesTo,appliesVs,- triggerDamageJobs: effectKind === "extra_attack" ? intent.trigger_damage_jobs : undefined,+ triggerDamageJobs: compiled.triggerDamageJobs,createdRound: round,- startRound: round + delay,- duration,+ startRound: Math.max(1, round + compiled.turnDelay),+ duration: compiled.duration,+ remainingAttackDelay: compiled.attackDelay,uses: 0,- stackingKey: `${skill.side}:${skill.sourceKind}:${skillStackingSourceKey(skill)}:${skill.id}:${intent.id}`,- sameEffectStacking: normalizeSameEffectStacking(intent.same_effect_stacking)+ stackingKey: compiled.stackingKey,+ sameEffectStacking: compiled.sameEffectStacking};+ if (!compiled.valueEvolution) return effect;+ return { ...effect, valueEvolution: compiled.valueEvolution } as EvolvingActiveEffect;}function skillActivationSourceKey(skill: ResolvedSkill): string {@@ -134,30 +210,56 @@return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");}-export function isEffectActive(effect: ActiveEffect, round: number): boolean {- if (round < effect.startRound) return false;- return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {+ return effect.duration.attacks !== undefined;}-export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {- return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");-}--export function currentEffectValuePct(effect: ActiveEffect, round: number): number {- const baseValue = Number(effect.valuePct ?? 0);- if (!Number.isFinite(baseValue)) return 0;- const evolution = effect.intent.value_evolution;- if (!evolution) return baseValue;- const firstActiveRound = Math.max(1, effect.startRound);- const stepCount = evolution.step === "attack" ? effect.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;- const amount = Number(evolution.value ?? 0);- if (!Number.isFinite(amount) || stepCount <= 0) return baseValue;+export function effectAttackUseLimit(effect: ActiveEffect): number | undefined {+ const count = effect.duration.attacks?.count;+ return count === undefined ? undefined : Math.max(1, count);+}++export function effectRoundWindow(effect: ActiveEffect): { activationRound: number; expirationRound?: number } | undefined {+ const turns = effect.duration.turns;+ if (!turns) return undefined;+ return {+ activationRound: effect.startRound,+ ...(turns.count === undefined ? {} : { expirationRound: effect.startRound + Math.max(1, turns.count) })+ };+}++export function isEffectAttackReady(effect: ActiveEffect): boolean {+ return effect.remainingAttackDelay <= 0;+}++// Returns true when the effect may apply to this eligible attack. The attack+// that consumes the final delay does not also consume the first active use.+export function advanceEffectAttackDelay(effect: ActiveEffect): boolean {+ const remaining = effect.remainingAttackDelay;+ if (remaining <= 0) return true;+ effect.remainingAttackDelay = remaining - 1;+ return false;+}++export function constantActiveEffectValuePct(this: ActiveEffect, _round: number): number {+ return this.initialValuePct;+}++export function evolvingActiveEffectValuePct(this: EvolvingActiveEffect, round: number): number {+ const firstActiveRound = Math.max(1, this.startRound);+ const evolution = this.valueEvolution;+ const stepCount = evolution.step === "attack" ? this.uses : evolution.step === "round" || evolution.step === "turn" ? Math.max(0, round - firstActiveRound) : 0;+ if (stepCount <= 0) return this.initialValuePct;if (evolution.type === "pct_decay") {- const factor = Math.max(0, 1 - amount / 100);- return baseValue * factor ** stepCount;+ const factor = Math.max(0, 1 - evolution.amount / 100);+ return this.initialValuePct * factor ** stepCount;}- if (evolution.type === "fixed_decay") return Math.max(0, baseValue - stepCount * amount);- return baseValue;+ if (evolution.type === "fixed_decay") return Math.max(0, this.initialValuePct - stepCount * evolution.amount);+ return this.initialValuePct;+}++function finiteNumberOrZero(value: unknown): number {+ return typeof value === "number" && Number.isFinite(value) ? value : 0;}export type TriggerSelectorRelation = "self" | "enemy";@@ -183,9 +285,8 @@return relation === "self" ? skillSide : oppositeSide(skillSide);}-function triggerSelectorMatches(selector: ParsedTriggerSelector, skillSide: SideId, actualSide: SideId, actualUnit: UnitType): boolean {- if (sideForTriggerRelation(skillSide, selector.relation) !== actualSide) return false;- return selector.units === undefined || selector.units.includes(actualUnit);+function triggerScopeMatches(scope: ResolvedUnitScope, actualSide: SideId, actualUnit: UnitType): boolean {+ return scope.side === actualSide && unitMaskHas(scope.units, actualUnit);}export function normalizeEngagementType(value: unknown): string | undefined {@@ -237,40 +338,15 @@}function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {- if (!duration) return { type: "battle", value: 0 };- const namedConstraints = normalizeNamedDurationConstraints(duration);- if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };- return { type: "battle", value: 0 };-}--function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {- return duration.constraints ?? [{ type: duration.type, count: duration.value }];-}--function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {- const delay = Math.max(0, constraint.delay ?? 0);- if (constraint.type === "battle") return true;- if (constraint.type === "round") {- const startRound = effect.createdRound + delay;- if (round < startRound) return false;- if (constraint.count === undefined) return true;- return round < startRound + Math.max(1, constraint.count);- }- if (constraint.count === undefined) return true;- return effect.uses < delay + Math.max(1, constraint.count);-}--function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {- const constraints: EffectDurationConstraint[] = [];- const turns = duration.turns ?? duration.rounds;- if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));- if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));- return constraints;+ if (!duration) return {};+ return {+ ...(duration.turns ? { turns: normalizeDurationAxis(duration.turns) } : {}),+ ...(duration.attacks ? { attacks: normalizeDurationAxis(duration.attacks) } : {})+ };}-function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {+function normalizeDurationAxis(value: { count?: number; delay?: number }): { count?: number; delay?: number } {return {- type,...(value.count === undefined ? {} : { count: Number(value.count) }),...(value.delay === undefined ? {} : { delay: Number(value.delay) })};diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -566,6 +566,139 @@assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);});+test("cancelled attacks do not charge attack-limited effects before their turn delay elapses", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedAfterPause: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedAfterPause: {+ name: "DelayedAfterPause",+ troop_type: "infantry",+ skills: {+ PauseFirstRound: {+ trigger: { type: "battle_start" },+ effects: {+ pause: {+ type: "no_attack",+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ DelayedAttackBudget: {+ trigger: { type: "turn", every: 99, first: 1 },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const roundOne = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry"));+ const roundTwo = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ assert.equal(roundOne?.cancelReason, "no_attack");+ assert.equal(roundTwo?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});++test("attack delay skips eligible attacks before the effect can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { marksman_t1: 1000000 },+ heroes: { DelayedExtra: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedExtra: {+ name: "DelayedExtra",+ troop_type: "marksman",+ skills: {+ NextAttack: {+ trigger: { type: "battle_start" },+ effects: {+ delayedExtra: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "self.marksman", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }]+ }+ }+ }+ }+ }+ })+ );++ const skillAttacks = result.attacks.filter((attack) => attack.kind === "skill" && attack.attackerSide === "attacker");+ assert.equal(skillAttacks.length, 1);+ assert.ok(skillAttacks[0].jobId.startsWith("r2:"));+});++test("attack delay skips eligible damage jobs before a modifier can apply", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedModifier: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedModifier: {+ name: "DelayedModifier",+ troop_type: "infantry",+ skills: {+ NextAttackBoost: {+ trigger: { type: "battle_start" },+ effects: {+ delayedBoost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1, delay: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter(+ (attack) => attack.kind === "normal" && attack.attackerSide === "attacker"+ );+ assert.equal(normalAttacks[0].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);+ assert.equal(normalAttacks[1].appliedEffects.some((effect) => effect.effectId === "delayedBoost"), true);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle(diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -28,20 +28,32 @@import { createRecorder } from "./recorder";import {activateEffect,+ advanceEffectAttackDelay,chancePasses,+ compiledTriggerForSkill,+ constantActiveEffectValuePct,createSeededRng,- currentEffectValuePct,+ effectAttackUseLimit,+ effectRoundWindow,hasAttackDurationConstraint,- isEffectActive,+ isEffectAttackReady,oppositeSide,- parseTriggerSelector,- sideForTriggerRelation,skillMatchesTrigger,sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import {+ cloneEffectIndex,+ createEffectIndex,+ damageEffectsForJob,+ damageJobShapeSlot,+ DAMAGE_JOB_SHAPE_SLOTS,+ expireEffectIndex,+ indexEffect,+ isRuntimeIndexableEffect,+ type EffectIndex+} from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -53,11 +65,13 @@const REPORT_KEY_CACHE = new WeakMap<ResolvedSkill, string>();interface Runtime {- activeEffects: ActiveEffect[];effectIndex: EffectIndex;+ preparedEffects: ActiveEffect[];+ activateEffectsByRound: Array<ActiveEffect[] | undefined>;+ expireEffectsByRound: Array<ActiveEffect[] | undefined>;// Per-job scratch: effects that affected the job being calculated; drained by// chargeUsedEffects (uses += 1 each) after every job in every mode.- usedEffects: Set<ActiveEffect>;+ usedEffects: ActiveEffect[];staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;@@ -79,6 +93,7 @@roundStartGlobal: ResolvedSkill[];roundStartPerUnit: ResolvedSkill[];attackDeclared: ResolvedSkill[];+ attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined>;}interface ExtraAttackEffectGroup {@@ -215,33 +230,47 @@}// Build the full pre-loop runtime: fire battle_start, apply input passives, compile the static damage-// profile, and drop static-profile effects from the per-job index.-function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number): Runtime {- const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed));- triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);- addInputPassiveEffects(runtime, input.attacker.passive, "attacker");- addInputPassiveEffects(runtime, input.defender.passive, "defender");- runtime.staticDamageProfile = buildStaticDamageProfile(fighters, runtime.activeEffects);- removeStaticProfileBucketEffects(runtime.effectIndex);+// profile. Static-phase effects never enter the per-job index.+function setupRuntime(fighters: Record<SideId, ResolvedFighter>, input: BattleInput, seed: string | number, collectSkillReports = true): Runtime {+ const runtime = createRuntime([fighters.attacker, fighters.defender], createSeededRng(seed), collectSkillReports);+ const setupEffects = [+ ...triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime),+ ...addInputPassiveEffects(runtime, input.attacker.passive, "attacker"),+ ...addInputPassiveEffects(runtime, input.defender.passive, "defender")+ ];+ runtime.staticDamageProfile = buildStaticDamageProfile(fighters, setupEffects);+ runtime.preparedEffects = setupEffects.filter(isRuntimeIndexableEffect);return runtime;}-// Clone a prepared template's mutable per-run state. Effects are shallow-cloned (only `uses` mutates)-// and the index rebuilt from the clones; skills and the static profile are shared by reference.-function cloneRuntime(template: Runtime, rng: Rng): Runtime {- const activeEffects = template.activeEffects.map((effect) => ({ ...effect }));- const effectIndex = createEffectIndex();- for (const effect of activeEffects) indexEffect(effectIndex, effect);- removeStaticProfileBucketEffects(effectIndex);- return {- activeEffects,- effectIndex,- usedEffects: new Set(),+// Clone a prepared template's mutable per-run effect graph. The template already owns the+// correct index topology and round schedules, so remap those references to the effect clones+// instead of classifying and scheduling every effect again. Skills and the immutable static+// profile are shared by reference.+function cloneRuntime(template: Runtime, rng: Rng, collectSkillReports = true): Runtime {+ const effectClones = new Map<ActiveEffect, ActiveEffect>();+ const preparedEffects = template.preparedEffects.map((effect) => {+ const clone = { ...effect };+ effectClones.set(effect, clone);+ return clone;+ });+ const cloneEffect = (effect: ActiveEffect): ActiveEffect => {+ const clone = effectClones.get(effect);+ if (!clone) throw new Error(`prepared effect ${effect.id} is missing from the runtime template`);+ return clone;+ };++ const runtime: Runtime = {+ effectIndex: cloneEffectIndex(template.effectIndex, cloneEffect),+ preparedEffects,+ activateEffectsByRound: cloneEffectSchedule(template.activateEffectsByRound, cloneEffect),+ expireEffectsByRound: cloneEffectSchedule(template.expireEffectsByRound, cloneEffect),+ usedEffects: [],staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,skills: template.skills,- skillReports: cloneSkillReports(template.skillReports),+ skillReports: collectSkillReports ? cloneSkillReports(template.skillReports) : { attacker: new Map(), defender: new Map() },effectActivationCounts: { ...template.effectActivationCounts },extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },attackControlCounts: { ...template.attackControlCounts },@@ -250,6 +279,14 @@received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }}};+ return runtime;+}++function cloneEffectSchedule(+ schedule: Array<ActiveEffect[] | undefined>,+ cloneEffect: (effect: ActiveEffect) => ActiveEffect+): Array<ActiveEffect[] | undefined> {+ return schedule.map((effects) => effects?.map(cloneEffect));}function cloneSkillReports(reports: Record<SideId, Map<string, SkillReportEntry>>): Record<SideId, Map<string, SkillReportEntry>> {@@ -288,18 +325,19 @@prepared?: CompiledBattle,loopOptions: RunLoopOptions = { capRoundKills: true, capJobKills: true, commitLosses: true }): BattleRun {+ const collectSkillReports = (options.mode ?? "standard") !== "fast";if (prepared?.template) {const fighters: Record<SideId, ResolvedFighter> = {attacker: cloneFighterForRun(prepared.fighters.attacker),defender: cloneFighterForRun(prepared.fighters.defender)};- const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"));+ const runtime = cloneRuntime(prepared.template, createSeededRng(input.seed ?? "simulator-default"), collectSkillReports);return runLoop(input, fighters, runtime, options, loopOptions);}const attacker = prepared ? cloneFighterForRun(prepared.fighters.attacker) : resolveFighter(input.attacker, "attacker", config, input.engagement_type);const defender = prepared ? cloneFighterForRun(prepared.fighters.defender) : resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default");+ const runtime = setupRuntime(fighters, input, input.seed ?? "simulator-default", collectSkillReports);return runLoop(input, fighters, runtime, options, loopOptions);}@@ -317,6 +355,15 @@dodge: options.useEffectsOnDodge ?? true,no_attack: options.useEffectsOnNoAttack ?? true};+ const damageJobOptions = {+ trace: recorder.capturesTrace,+ recordAppliedEffects: recorder.capturesAppliedEffects,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects+ };let rounds = 0;let score = 0;@@ -324,7 +371,7 @@if (winnerFor(fighters)) break;rounds = round;const roundStartTroops = snapshotTroops(fighters);- expireInactive(runtime, round);+ processEffectSchedule(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);// Applied-effect events keyed by the intent they ordered.@@ -340,13 +387,16 @@const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {- triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);+ const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[+ damageJobShapeSlot("normal", intent.attackerSide, intent.attackerUnit, intent.defenderSide, intent.defenderUnit)+ ] ?? [];+ triggerSkills("attack_declared", round, matchingTriggerSkills, runtime, intent);const job = normalJob(intent, roundStartTroops);declaredNormalJobs.push({ intent, job });}for (const { intent, job } of declaredNormalJobs) {- const controls = applicableControls(job, round, runtime);+ const controls = applicableControls(job, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;pendingNormalJobs.push({ intent, job, control });@@ -364,7 +414,9 @@if (control) {runtime.attackControlCounts[control.reason] += 1;- if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ if (useEffectsOnCancel[control.reason]) {+ chargeCancelledAttack(job, control.effect, control.attackDurationEffects, runtime);+ }cancelled.push({intent,effectId: control.effect.id,@@ -376,16 +428,8 @@continue;}- allJobs.push(job);- const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,- usedEffects: runtime.usedEffects- });+ if (recorder.capturesTrace) allJobs.push(job);+ const normalResult = calculateDamageJob(job, fighters, [], damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;@@ -404,16 +448,8 @@for (const extraJob of extraSkill.jobs) {if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;- allJobs.push(extraJob);- const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {- trace: recorder.capturesTrace,- recordAppliedEffects: recorder.capturesAppliedEffects,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills,- usedEffects: runtime.usedEffects- });+ if (recorder.capturesTrace) allJobs.push(extraJob);+ const extraResult = calculateDamageJob(extraJob, fighters, [], damageJobOptions);if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");processedExtraJobIds.add(extraJob.id);@@ -429,7 +465,7 @@normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));for (const usedEffectGroup of extraSkill.usedEffectGroups) {if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;- for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ for (const usedEffect of usedEffectGroup.effects) chargeEffectUse(runtime, usedEffect);}}@@ -501,7 +537,8 @@const side = roundTriggerSourceSide(skill);const defenderSide = roundTriggerTargetSide(skill, side);if (!skillMatchesTrigger(skill, "round_start", round)) continue;- const report = runtime.skillReports[skill.side].get(reportKey(skill));+ const reports = runtime.skillReports[skill.side];+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));if (report) report.triggersSeen += 1;if (!chancePasses(skill, runtime.rng)) continue;if (report) report.skillActivations += 1;@@ -529,20 +566,16 @@}function roundTriggerUnits(skill: ResolvedSkill, roundStartTroops: DamageJob["roundStartTroops"]): UnitType[] {- const selector = parseTriggerSelector(skill.trigger.source, "self");- const side = sideForTriggerRelation(skill.side, selector.relation);- const living = UNIT_TYPES.filter((unit) => (roundStartTroops[side][unit] ?? 0) > 0);- return selector.units === undefined ? living : living.filter((unit) => selector.units?.includes(unit));+ const source = compiledTriggerForSkill(skill).source;+ return UNIT_TYPES.filter((unit) => (roundStartTroops[source.side][unit] ?? 0) > 0 && unitMaskHas(source.units, unit));}function roundTriggerSourceSide(skill: ResolvedSkill): SideId {- const selector = parseTriggerSelector(skill.trigger.source, "self");- return sideForTriggerRelation(skill.side, selector.relation);+ return compiledTriggerForSkill(skill).source.side;}function roundTriggerTargetSide(skill: ResolvedSkill, sourceSide: SideId): SideId {- const selector = parseTriggerSelector(skill.trigger.target, "enemy");- const targetSide = sideForTriggerRelation(skill.side, selector.relation);+ const targetSide = compiledTriggerForSkill(skill).target.side;return targetSide === sourceSide ? oppositeSide(sourceSide) : targetSide;}@@ -589,10 +622,10 @@return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {+function createRuntime(fighters: ResolvedFighter[], rng: Rng, collectSkillReports = true): Runtime {const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };const skills = buildRuntimeSkills(fighters);- for (const fighter of fighters) {+ for (const fighter of collectSkillReports ? fighters : []) {for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {reports[fighter.side].set(reportKey(skill), {sourceKind: skill.sourceKind,@@ -610,9 +643,11 @@}}return {- activeEffects: [],effectIndex: createEffectIndex(),- usedEffects: new Set(),+ preparedEffects: [],+ activateEffectsByRound: [],+ expireEffectsByRound: [],+ usedEffects: [],staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,@@ -630,45 +665,81 @@function buildRuntimeSkills(fighters: ResolvedFighter[]): RuntimeSkills {const all = fighters.flatMap((fighter) => [...(fighter.heroSkills ?? []), ...fighter.troopSkills]);+ for (const skill of all) compiledTriggerForSkill(skill);const battleStart = all.filter((skill) => skill.trigger.type === "battle_start");const roundStart = all.filter((skill) => skill.trigger.type === "turn");const attackDeclared = all.filter((skill) => skill.trigger.type === "attack");+ const attackDeclaredByJobShape: Array<ResolvedSkill[] | undefined> = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS });+ for (const skill of attackDeclared) {+ const trigger = compiledTriggerForSkill(skill);+ for (const attackerUnit of unitsFromMask(trigger.source.units)) {+ for (const defenderUnit of unitsFromMask(trigger.target.units)) {+ const slot = damageJobShapeSlot("normal", trigger.source.side, attackerUnit, trigger.target.side, defenderUnit);+ const skills = attackDeclaredByJobShape[slot];+ if (skills) skills.push(skill);+ else attackDeclaredByJobShape[slot] = [skill];+ }+ }+ }return {all,battleStart,roundStart,roundStartGlobal: roundStart.filter((skill) => !hasPerUnitRoundTrigger(skill)),roundStartPerUnit: roundStart.filter((skill) => hasPerUnitRoundTrigger(skill)),- attackDeclared+ attackDeclared,+ attackDeclaredByJobShape};}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {- runtime.activeEffects.push(effect);- indexEffect(runtime.effectIndex, effect);+ if (!isRuntimeIndexableEffect(effect)) return;+ const window = effectRoundWindow(effect);+ if (!window) {+ indexEffect(runtime.effectIndex, effect);+ return;+ }+ if (window.expirationRound !== undefined) {+ if (window.expirationRound <= window.activationRound) {+ effect.expired = true;+ return;+ }+ scheduleEffect(runtime.expireEffectsByRound, window.expirationRound, effect);+ }+ if (window.activationRound <= effect.createdRound) indexEffect(runtime.effectIndex, effect);+ else scheduleEffect(runtime.activateEffectsByRound, window.activationRound, effect);+}++function scheduleEffect(schedule: Array<ActiveEffect[] | undefined>, round: number, effect: ActiveEffect): void {+ const effects = schedule[round];+ if (effects) effects.push(effect);+ else schedule[round] = [effect];}-function expireInactive(runtime: Runtime, round: number): void {- let write = 0;- for (let read = 0; read < runtime.activeEffects.length; read += 1) {- const effect = runtime.activeEffects[read];- if (isEffectActive(effect, round)) {- runtime.activeEffects[write] = effect;- write += 1;+function processEffectSchedule(runtime: Runtime, round: number): void {+ const expiring = runtime.expireEffectsByRound[round];+ if (expiring) {+ for (const effect of expiring) expireActiveEffect(runtime, effect);+ runtime.expireEffectsByRound[round] = undefined;+ }+ const activating = runtime.activateEffectsByRound[round];+ if (activating) {+ for (const effect of activating) {+ if (!effect.expired) indexEffect(runtime.effectIndex, effect);}+ runtime.activateEffectsByRound[round] = undefined;}- runtime.activeEffects.length = write;- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));}-function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {- if (!passive) return;+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): ActiveEffect[] {+ const effects: ActiveEffect[] = [];+ if (!passive) return effects;for (const stat of ["attack", "defense", "lethality", "health"] as const) {for (const direction of ["up", "down"] as const) {const valuePct = Number(passive[stat]?.[direction] ?? 0);if (!Number.isFinite(valuePct) || valuePct <= 0) continue;const bucket = `passive.${stat}.${direction}`;- addActiveEffect(runtime, {+ const effect: ActiveEffect = {id: `${side}:input_stat:${bucket}`,source: {kind: "input_stat",@@ -682,17 +753,23 @@},ownerSide: side,kind: "modifier",- valuePct,+ bucketIndex: -1,+ initialValuePct: valuePct,+ getCurrentValuePct: constantActiveEffectValuePct,appliesTo: { side, units: ALL_UNIT_MASK },appliesVs: { side: oppositeSide(side), units: ALL_UNIT_MASK },createdRound: 0,startRound: 0,- duration: { type: "battle", value: 0 },+ duration: {},+ remainingAttackDelay: 0,uses: 0,sameEffectStacking: "add"- });+ };+ addActiveEffect(runtime, effect);+ effects.push(effect);}}+ return effects;}function triggerSkills(@@ -705,7 +782,8 @@const activated: ActiveEffect[] = [];for (const skill of skills) {if (!skillMatchesTrigger(skill, triggerType, round, intent)) continue;- const report = runtime.skillReports[skill.side].get(reportKey(skill));+ const reports = runtime.skillReports[skill.side];+ const report = reports.size === 0 ? undefined : reports.get(reportKey(skill));if (report) report.triggersSeen += 1;if (!chancePasses(skill, runtime.rng)) continue;if (report) report.skillActivations += 1;@@ -732,12 +810,12 @@let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, true);const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);if (!defenderUnit) continue;const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;if (ordered) {- ordered.effect.uses += 1;+ chargeEffectUse(runtime, ordered.effect);orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });}const previousAttackCount = runtime.counters.attacks[side][attackerUnit];@@ -772,7 +850,7 @@effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, false)?.order ?? UNIT_TYPES;return firstLivingUnit(order, defenderSide, roundStartTroops);}@@ -784,13 +862,14 @@attackerUnit: UnitType,attackerSide: SideId,index: EffectIndex,- round: number+ advanceAttackDelay: boolean): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {- if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;+ if (effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {+ if (advanceAttackDelay ? !advanceEffectAttackDelay(effect) : !isEffectAttackReady(effect)) continue;return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;@@ -802,23 +881,34 @@function applicableControls(job: DamageJob,- round: number,runtime: Runtime-): { dodge?: Control; no_attack?: Control } {- const controls: { dodge?: Control; no_attack?: Control } = {};+): ApplicableControls {+ const controls: ApplicableControls = { attackDurationEffects: [] };for (const effect of runtime.effectIndex.controls) {- if (!isEffectActive(effect, round)) continue;const classification = classifyEffectForJob(effect, job);if (classification?.kind === "control" && classification.control) {- controls[classification.control] = { effect, reason: classification.control };+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) controls.attackDurationEffects.push(effect);+ controls[classification.control] = {+ effect,+ reason: classification.control,+ attackDurationEffects: controls.attackDurationEffects+ };}}return controls;}+interface ApplicableControls {+ dodge?: Control;+ no_attack?: Control;+ attackDurationEffects: ActiveEffect[];+}+interface Control {effect: ActiveEffect;reason: "dodge" | "no_attack";+ attackDurationEffects: ActiveEffect[];}function normalJob(intent: AttackIntent, roundStartTroops: DamageJob["roundStartTroops"]): DamageJob {@@ -847,7 +937,9 @@const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(- runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),+ runtime.effectIndex.extraAttacks.filter(+ (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)+ ),round);for (const effectGroup of effectGroups) {@@ -916,7 +1008,7 @@}const existing = selected[existingIndex];existing.effects.push(effect);- if (currentEffectValuePct(effect, round) > currentEffectValuePct(existing.selected, round)) existing.selected = effect;+ if (effect.getCurrentValuePct(round) > existing.selected.getCurrentValuePct(round)) existing.selected = effect;}return selected;}@@ -979,7 +1071,7 @@}function multiplierForTriggerDamageJob(multiplier: number | undefined, effect: ActiveEffect, round: number): number {- const raw = multiplier === undefined ? currentEffectValuePct(effect, round) : multiplier;+ const raw = multiplier === undefined ? effect.getCurrentValuePct(round) : multiplier;const pct = Number(raw ?? 0);return Number.isFinite(pct) ? pct / 100 : 0;}@@ -1054,24 +1146,38 @@// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.function chargeUsedEffects(runtime: Runtime): void {- if (runtime.usedEffects.size === 0) return;- for (const effect of runtime.usedEffects) effect.uses += 1;- runtime.usedEffects.clear();+ if (runtime.usedEffects.length === 0) return;+ for (const effect of runtime.usedEffects) chargeEffectUse(runtime, effect);+ runtime.usedEffects.length = 0;+}++function chargeEffectUse(runtime: Runtime, effect: ActiveEffect): void {+ effect.uses += 1;+ const limit = effectAttackUseLimit(effect);+ if (limit !== undefined && effect.uses >= limit) expireActiveEffect(runtime, effect);+}++function expireActiveEffect(runtime: Runtime, effect: ActiveEffect): void {+ if (effect.expired) return;+ expireEffectIndex(runtime.effectIndex, effect);}// A cancelled attack still charges the attacker's attack-constrained effects (the attack// happened, it just didn't land) plus the control that cancelled it, whatever its duration.-function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {+function chargeCancelledAttack(+ job: DamageJob,+ winningControl: ActiveEffect,+ controlEffects: ActiveEffect[],+ runtime: Runtime+): void {const used = runtime.usedEffects;- for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {- if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);- }- for (const effect of runtime.effectIndex.controls) {- if (!hasAttackDurationConstraint(effect)) continue;- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") used.add(effect);+ for (const effect of damageEffectsForJob(runtime.effectIndex, job)) {+ if (effect.expired) continue;+ if (!advanceEffectAttackDelay(effect)) continue;+ if (hasAttackDurationConstraint(effect)) used.push(effect);}- used.add(winningControl);+ for (const effect of controlEffects) used.push(effect);+ if (!controlEffects.includes(winningControl)) used.push(winningControl);chargeUsedEffects(runtime);}diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts--- a/simulator/src/staticDamageProfile.ts+++ b/simulator/src/staticDamageProfile.ts@@ -1,7 +1,7 @@import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";-import { currentEffectValuePct, sourceLabel } from "./effects";+import { sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;@@ -11,6 +11,7 @@export interface StaticDamageProfileEntry {factor: number;+ playerFactorsValid: boolean;buckets: Partial<Record<StaticDamageBucket, StaticDamageProfileTerm>>;}@@ -90,7 +91,7 @@const duration = effect.duration;if (duration === undefined) return;- if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {+ if (duration.turns !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}}@@ -130,7 +131,7 @@{ effectId: "input:defense", source: "input_stats", valuePct: bonuses.defense, bucket: "player.defense" }]);}- return { factor: 1, buckets };+ return { factor: 1, playerFactorsValid: true, buckets };}function applyStaticPassives(profile: StaticDamageProfile, activeEffects: ActiveEffect[]): void {@@ -142,7 +143,7 @@const targetEntries = role === "attacker" ? profile.offense[effect.appliesTo.side] : profile.defense[effect.appliesTo.side];for (const unit of UNIT_TYPES) {if (!unitMaskHas(effect.appliesTo.units, unit)) continue;- const candidate: PassiveCandidate = { effect, bucket, valuePct: currentEffectValuePct(effect, 1) };+ const candidate: PassiveCandidate = { effect, bucket, valuePct: effect.getCurrentValuePct(1) };if (effect.sameEffectStacking === "max" && effect.stackingKey) {const key = `${role}:${effect.appliesTo.side}:${unit}:${bucket}:${effect.stackingKey}`;const group = groups.get(key);@@ -177,12 +178,20 @@function recomputeFactors(profile: StaticDamageProfile): void {for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {- profile.offense[side][unit].factor = offenseFactor(profile.offense[side][unit]);- profile.defense[side][unit].factor = defenseFactor(profile.defense[side][unit]);+ const offense = profile.offense[side][unit];+ const defense = profile.defense[side][unit];+ offense.factor = offenseFactor(offense);+ defense.factor = defenseFactor(defense);+ offense.playerFactorsValid = playerPctFactorValid(offense, "player.attack") && playerPctFactorValid(offense, "player.lethality");+ defense.playerFactorsValid = playerPctFactorValid(defense, "player.health") && playerPctFactorValid(defense, "player.defense");}}}+function playerPctFactorValid(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket): boolean {+ return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100 > 0;+}+interface StaticFactorTerm {bucket: StaticDamageBucket;valueType: BucketValueType;diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -74,11 +74,14 @@export type TroopStatsCatalogue = Record<string, TroopStatsRecord>;export type HeroGenerationStatsCatalogue = Record<string, Partial<StatBlock>>;-export interface EffectDuration {- type: "battle" | "round" | "attack";- value: number;+export interface EffectDurationAxis {+ count?: number;delay?: number;- constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;+}++export interface EffectDuration {+ turns?: EffectDurationAxis;+ attacks?: EffectDurationAxis;}export interface EffectIntentDefinition {@@ -88,11 +91,7 @@value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: {- turns?: { count?: number; delay?: number };- rounds?: { count?: number; delay?: number };- attacks?: { count?: number; delay?: number };- };+ duration?: EffectDuration;same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -205,6 +204,14 @@level: number;trigger: TriggerDefinition;effects: EffectIntentDefinition[];+ compiledTrigger?: {+ definition: TriggerDefinition;+ side: SideId;+ level: number;+ source: ResolvedUnitScope;+ target: ResolvedUnitScope;+ probabilityPct: number;+ };}export interface ResolvedHero {@@ -242,11 +249,16 @@export interface ActiveEffect {id: string;+ expired?: boolean;source: EffectSource;intent: EffectIntentDefinition;ownerSide: SideId;kind: ActiveEffectKind;- valuePct?: number;+ // Numeric slot in the runtime damage scratch. Dynamic modifiers receive it+ // when indexed; non-damage effects keep -1.+ bucketIndex: number;+ initialValuePct: number;+ getCurrentValuePct(round: number): number;// Resolved ActiveEffect usage gates. Native applies_vs config accepts "any",// trigger-relative selectors, or concrete unit selectors; it does not accept "all".appliesTo: ResolvedUnitScope;@@ -255,6 +267,9 @@createdRound: number;startRound: number;duration: EffectDuration;+ // Eligible attacks remaining before an attacks.delay effect can apply. This is+ // runtime state resolved from config, separate from `uses` after activation.+ remainingAttackDelay: number;// Times this instance affected battle mechanics (damage bucket applied, control fired,// attack ordered, extra attack spawned). Attack duration constraints and step:"attack"// value evolution read it; cancelled attacks still charge attack-constrained effects@@ -264,6 +279,14 @@sameEffectStacking: SameEffectStacking;}+export interface EvolvingActiveEffect extends ActiveEffect {+ valueEvolution: {+ type?: string;+ step?: string;+ amount: number;+ };+}+export interface AttackIntent {id: string;round: number;