← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.20%
Avg Error B0.19%
Δ Avg Error-0.01%
Improved4
Regressed0
Added0
Retired0
Testcase Delta
201 / 201
Code / Config Changes
Commits (79ed9788 → dd63eaee)
dd63eaeeUpdate dashboard test results databasepiddlyminx05/07/2026, 02:10:44
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/Ahmose.json===================================================================--- simulator/config/hero_definitions/Ahmose.json prev run+++ simulator/config/hero_definitions/Ahmose.json this run@@ -6,9 +6,10 @@"ViperFormation": {"description": "His infantry pauses the attack once every 4 times reducing damage taken by Lancers and Marksmen by X% and Infantry by X% for 2 turns","trigger": {"type": "attack",- "every": 4,+ "first": 4,+ "every": 5,"source": "infantry"},"effects": {"ViperFormation/1": {Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -5,9 +5,9 @@"skills": {"EagleVision": {"description": "Increasing target's damage taken by X%","trigger": {- "type": "turn",+ "type": "attack","source": "self.any"},"effects": {"EagleVision/1": {@@ -31,12 +31,14 @@}}},"AirDominance": {- "description": "Grants all troops' attack X% extra damage after every 4 attacks and causes the target to receive X% extra damage for its next attack received",+ "description": "Grants all troops' attack X% extra damage after every 5 attacks and causes the target to receive X% extra damage for its next attack received","trigger": {- "type": "attack",- "every": 4+ "type": "turn",+ "first": 5,+ "every": 6,+ "source": "self.all"},"effects": {"AirDominance/1": {"type": "extra_skill_attack",@@ -48,9 +50,9 @@100],"units": {"applies_to": "trigger.source",- "applies_vs": "trigger.target"+ "applies_vs":"trigger.target"},"duration": {"attacks": {"count": 1@@ -58,9 +60,9 @@},"trigger_damage_jobs": [{"source": "use.source",- "target": "effect.applies_vs"+ "target": "use.target"}]},"AirDominance/2": {@@ -72,14 +74,18 @@12.5,15],"units": {- "applies_to": "target"+ "applies_to": "trigger.target"},+ "same_effect_stacking": "max","duration": {- "attacks": {+ "turns": {"count": 1,"delay": 1+ },+ "attacks": {+ "count": 1}}}}Index: simulator/config/hero_definitions/Reina.json===================================================================--- simulator/config/hero_definitions/Reina.json prev run+++ simulator/config/hero_definitions/Reina.json this run@@ -44,9 +44,10 @@"effects": {"SwiftJive/1": {"type": "dodge","units": {- "applies_to": "trigger"+ "applies_to": "target",+ "applies_vs": "trigger.source"},"duration": {"attacks": {"count": 1Index: simulator/src/classifier.ts===================================================================--- simulator/src/classifier.ts prev run+++ simulator/src/classifier.ts this run@@ -10,12 +10,15 @@reason?: string;}export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {- if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };-const type = effect.intent.type;- if (type === "dodge" || type === "no_attack") return { kind: "control", control: type };+ if (type === "dodge" || type === "no_attack") {+ if (!controlEffectApplies(effect, job, type)) return { kind: "report_only", reason: "not_applicable_to_job" };+ return { kind: "control", control: type };+ }++ if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };if (type === "attack_order") return { kind: "battle_order" };const definition = bucketDefinition(type);@@ -40,8 +43,18 @@if (!opposingUnit || !unitMaskHas(effect.appliesVs.units, opposingUnit)) return false;return true;}+function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {+ const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;+ const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;+ if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;++ const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;+ const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;+ return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);+}+function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";return "wrong_side";Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -214,8 +214,14 @@const legacyUnits = (trigger as unknown as Record<string, unknown>).units;if (legacyUnits !== undefined) {throw new Error(`legacy trigger units filters are not supported at ${file}:${skillId}.trigger.units; use trigger.source and trigger.target`);}+ if (trigger.first !== undefined && (!Number.isFinite(Number(trigger.first)) || Number(trigger.first) < 1)) {+ throw new Error(`trigger.first must be a positive number at ${file}:${skillId}.trigger.first`);+ }+ if (trigger.first !== undefined && trigger.every === undefined) {+ throw new Error(`trigger.first requires trigger.every at ${file}:${skillId}.trigger`);+ }}function isTriggerRelativeUnitSelector(selector: unknown): selector is string {return selector === "trigger" || selector === "trigger.source" || selector === "target" || selector === "trigger.target";Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -105,14 +105,15 @@export function calculateDamageJob(job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: 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?: Set<ActiveEffect> }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");// The damage math is one path; `trace` only decides whether we also capture the (expensive)// per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.const traceEnabled = options.trace === true;+ const recordAppliedEffects = traceEnabled || options.recordAppliedEffects === true;const detail: DamageDetail = traceEnabled ? "full" : "fast";const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);const attacker = fighters[job.attackerSide];const defender = fighters[job.defenderSide];@@ -154,11 +155,11 @@rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });}}- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);+ 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";@@ -182,17 +183,18 @@: undefined;return {kills,- appliedEffects: traceEnabled ? appliedEffects : undefined,+ appliedEffects: recordAppliedEffects ? appliedEffects : undefined,trace};}function applyBucketCandidates(candidates: BucketCandidate[],buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {@@ -208,14 +210,14 @@} else {maxGroups.set(key, { selected: candidate, candidates: [candidate] });}} else {- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);+ applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the@@ -223,8 +225,9 @@function applySelectedBucket(selected: BucketCandidate,buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"]): number {const appliedValuePct = applyBucketValue(buckets,@@ -236,9 +239,9 @@selected.bucket,selected.effect.stackingKey,selected.effect.sameEffectStacking);- if (appliedValuePct !== 0 && detail === "full") {+ if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",activeEffectId: selected.effect.id,effectId: selected.effect.source.effectId ?? selected.effect.id,@@ -259,13 +262,14 @@function applyBucketCandidate(candidate: BucketCandidate,buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);+ const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {usedEffects.add(candidate.effect);} else if (detail === "full") {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });@@ -276,13 +280,14 @@selected: BucketCandidate,candidates: BucketCandidate[],buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, 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);@@ -556,5 +561,4 @@function pctFromFactor(factor: number): number {return Number(((factor - 1) * 100).toFixed(12));}-Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -36,10 +36,10 @@const trigger = skill.trigger;if (triggerType === "battle_start" && trigger.type !== "battle_start") return false;if (triggerType === "round_start" && trigger.type !== "turn") return false;if (triggerType === "attack_declared" && trigger.type !== "attack") return false;- if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every)) return false;- if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every)) 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);return (triggerSelectorMatches(selectors.source, skill.side, intent.attackerSide, intent.attackerUnit) &&@@ -75,10 +75,12 @@return state / 0x100000000;};}-export function crossedFrequency(previous: number, current: number, frequency: number): boolean {- return Math.floor(previous / frequency) < Math.floor(current / frequency);+export function crossedFrequency(previous: number, current: number, frequency: number, first = frequency): boolean {+ if (current < first) return false;+ 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 {const units = intent.units ?? {};Index: simulator/src/recorder.ts===================================================================--- simulator/src/recorder.ts prev run+++ simulator/src/recorder.ts this run@@ -22,8 +22,10 @@*/export interface BattleRecorder {/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */readonly capturesTrace: boolean;+ /** When true the loop records lightweight "effect applied" events on each AttackOutcome. */+ readonly capturesAppliedEffects: boolean;recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;readonly attacks: AttackOutcome[];@@ -34,8 +36,9 @@const NO_APPLIED_EFFECTS: AppliedEffect[] = [];export const NULL_RECORDER: BattleRecorder = {capturesTrace: false,+ capturesAppliedEffects: false,recordCancelled() {},recordDamageJob() {},recordRound() {},attacks: NO_ATTACKS,@@ -54,8 +57,9 @@class RecordingRecorder implements BattleRecorder {readonly attacks: AttackOutcome[] = [];readonly trace: BattleTrace | undefined;readonly capturesTrace: boolean;+ readonly capturesAppliedEffects = true;constructor(private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,trace: BattleTrace | undefined@@ -66,8 +70,9 @@recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {this.attacks.push({jobId: `${intent.id}:cancelled`,+ round: intent.round,kind: "normal",attackerSide: intent.attackerSide,attackerUnit: intent.attackerUnit,defenderSide: intent.defenderSide,@@ -90,8 +95,9 @@}const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";this.attacks.push({jobId: job.id,+ round: job.round,kind: job.kind,sourceEffectId: job.sourceEffectId,sourceSkillReportKey: job.sourceSkillReportKey,attackerSide: job.attackerSide,Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -61,8 +61,47 @@assert.deepEqual(result.resolved.attacker.heroes, []);assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));});+test("standard battle outcomes include round and applied effects without full traces", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: { Booster: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ }+ },+ minimalConfig({+ Booster: {+ name: "Booster",+ troop_type: "infantry",+ skills: {+ BattleBoost: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.attack.up",+ value: 25,+ units: { applies_to: "self.infantry", applies_vs: "any" }+ }+ }+ }+ }+ }+ })+ );++ const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ assert.equal(attack?.round, 1);+ assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);+ assert.equal(attack?.trace, undefined);+});+test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {const player: FighterInput = {name: "Player",troops: { infantry_t1: 100 },@@ -364,8 +403,90 @@assert.equal(round4LancerAttack?.defenderUnit, "marksman");assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);});+test("attack-duration effects with a round cap expire after one applicable attack", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { OneHit: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ OneHit: {+ name: "OneHit",+ troop_type: "infantry",+ skills: {+ OneAttackWindow: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 3 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const roundOneAttack = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry") && attack.kind === "normal");+ const roundTwoAttack = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);+ assert.equal(roundTwoAttack?.appliedEffects.some((effect) => effect.effectId === "boost"), false);+});++test("attack-duration effects with a round delay are not charged before the delay is over", () => {+ const result = simulateBattle(+ {+ maxRounds: 2,+ attacker: {+ troops: { infantry_t1: 1000000 },+ heroes: { DelayedOneHit: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DelayedOneHit: {+ name: "DelayedOneHit",+ troop_type: "infantry",+ skills: {+ DelayedAttackBudget: {+ trigger: { type: "turn" },+ 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 roundOneAttack = result.attacks.find((attack) => attack.jobId.startsWith("r1:attacker:infantry") && attack.kind === "normal");+ const roundTwoAttack = result.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");+ assert.equal(roundOneAttack?.appliedEffects.some((effect) => effect.effectId === "delayedBoost"), false);+ assert.equal(roundTwoAttack?.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({@@ -579,8 +700,130 @@{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }]);});+test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000, lancer_t1: 1000 },+ heroes: { StopInfantry: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000, lancer_t1: 1000 },+ heroes: {}+ }+ },+ minimalConfig({+ StopInfantry: {+ name: "StopInfantry",+ skills: {+ Pause: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ stop: {+ type: "no_attack",+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { turns: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:cancelled"]+ );+ assert.equal(+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),+ false+ );+});++test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1000, lancer_t1: 1000 },+ heroes: { Dodger: { skill_1: 1 } }+ }+ },+ minimalConfig({+ Dodger: {+ name: "Dodger",+ skills: {+ StepAside: {+ trigger: { type: "attack", source: "enemy.infantry", target: "self.infantry" },+ effects: {+ evade: {+ type: "dodge",+ units: { applies_to: "target", applies_vs: "trigger" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:cancelled"]+ );+ assert.equal(+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),+ false+ );+});++test("attack-declared controls from later intents affect earlier same-round attacks", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: { ReactiveDodge: { skill_1: 1 } }+ }+ },+ minimalConfig({+ ReactiveDodge: {+ name: "ReactiveDodge",+ skills: {+ StepAside: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ evade: {+ type: "dodge",+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:cancelled"]+ );+});+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {// Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is// active. By default the cancelled attack charges the buff, so round 2 lands without it;// with useEffectsOnNoAttack:false the buff survives to boost round 2.@@ -670,8 +913,46 @@["r2:attacker:infantry:0:cancelled", "r4:attacker:infantry:0:cancelled"]);});+test("attack frequency triggers can start at a different first threshold", () => {+ const result = simulateBattle(+ {+ maxRounds: 14,+ attacker: {+ troops: { infantry_t1: 10000 },+ heroes: { FirstThenEvery: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10000 },+ heroes: {}+ }+ },+ minimalConfig({+ FirstThenEvery: {+ name: "FirstThenEvery",+ skills: {+ Pause: {+ trigger: { type: "attack", first: 4, every: 5, source: "infantry" },+ effects: {+ cancel: {+ type: "no_attack",+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),+ ["r4:attacker:infantry:0:cancelled", "r9:attacker:infantry:0:cancelled", "r14:attacker:infantry:0:cancelled"]+ );+});+test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -321,21 +321,26 @@const roundStartTroops = snapshotTroops(fighters);expireInactive(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);- // Trace-only: battle_order applied events keyed by the intent they ordered.- const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;+ // Applied-effect events keyed by the intent they ordered.+ const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);const allJobs: DamageJob[] = []; // for recorderconst results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];- // Phase 1: fire all attack_declared triggers — all ActiveEffects are- // resolved before any damage is calculated, per the battle-core spec.+ // Phase 1: fire all attack_declared triggers for every intended attack before+ // evaluating any controls or damage, per the battle-core spec.const pendingNormalJobs: DamageJob[] = [];+ const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);+ declaredNormalJobs.push({ intent, job });+ }++ for (const { intent, job } of declaredNormalJobs) {const controls = applicableControls(job, round, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;runtime.attackControlCounts[control.reason] += 1;@@ -343,9 +348,9 @@cancelled.push({intent,effectId: control.effect.id,reason: control.reason,- appliedEffects: recorder.capturesTrace+ appliedEffects: recorder.capturesAppliedEffects? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control)): NO_APPLIED_EFFECTS});} else {@@ -360,8 +365,9 @@for (const job of pendingNormalJobs) {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,@@ -371,9 +377,9 @@score += normalResult.kills;}chargeUsedEffects(runtime);- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;results.push({job,result: normalResult,@@ -383,8 +389,9 @@for (const extraJob of extraSkill.jobs) {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,Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -99,8 +99,9 @@export interface TriggerDefinition {type: string;probability?: unknown;+ first?: number;every?: number;source?: unknown;target?: unknown;}@@ -367,8 +368,9 @@export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;export interface AttackOutcome {jobId: string;+ round: number;kind: DamageKind;sourceEffectId?: string;sourceSkillReportKey?: string;attackerSide: SideId;
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json--- a/simulator/config/hero_definitions/Ahmose.json+++ b/simulator/config/hero_definitions/Ahmose.json@@ -19,8 +19,9 @@]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}},"ViperFormation/2": {@@ -39,9 +40,10 @@]},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"},@@ -60,9 +62,10 @@]},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"}@@ -113,8 +116,9 @@]},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}},"BladeOfLight/2": {@@ -130,8 +134,9 @@"applies_to": "target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "add"}diff --git a/simulator/config/hero_definitions/Alonso.json b/simulator/config/hero_definitions/Alonso.json--- a/simulator/config/hero_definitions/Alonso.json+++ b/simulator/config/hero_definitions/Alonso.json@@ -31,8 +31,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -64,9 +65,10 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }},"same_effect_stacking": "max"}@@ -99,8 +101,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [diff --git a/simulator/config/hero_definitions/Bahiti.json b/simulator/config/hero_definitions/Bahiti.json--- a/simulator/config/hero_definitions/Bahiti.json+++ b/simulator/config/hero_definitions/Bahiti.json@@ -47,8 +47,9 @@"applies_to": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}diff --git a/simulator/config/hero_definitions/Bradley.json b/simulator/config/hero_definitions/Bradley.json--- a/simulator/config/hero_definitions/Bradley.json+++ b/simulator/config/hero_definitions/Bradley.json@@ -76,8 +76,9 @@30],"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Gordon.json b/simulator/config/hero_definitions/Gordon.json--- a/simulator/config/hero_definitions/Gordon.json+++ b/simulator/config/hero_definitions/Gordon.json@@ -26,8 +26,9 @@]},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}},"VenomInfusion/2": {@@ -43,8 +44,9 @@"applies_to": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -71,8 +73,9 @@]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"},@@ -90,8 +93,9 @@"applies_vs": "self.any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -117,8 +121,9 @@"applies_to": "enemy.infantry"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"},@@ -135,8 +140,9 @@"applies_to": "enemy.marksman"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Greg.json b/simulator/config/hero_definitions/Greg.json--- a/simulator/config/hero_definitions/Greg.json+++ b/simulator/config/hero_definitions/Greg.json@@ -31,8 +31,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 3+ "turns": {+ "count": 3+ }}}}@@ -65,8 +66,9 @@"applies_vs": "self.any"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}}}diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json--- a/simulator/config/hero_definitions/Gwen.json+++ b/simulator/config/hero_definitions/Gwen.json@@ -24,8 +24,9 @@"applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -51,8 +52,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -60,7 +62,7 @@"target": "effect.applies_vs"}]- },+ },"AirDominance/2": {"type": "active.hero.damageTaken.up","value": [@@ -74,9 +76,10 @@"applies_to": "target"},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "attacks": {+ "count": 1,+ "delay": 1+ }}}}@@ -104,9 +107,10 @@]},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "attacks": {+ "count": 1,+ "delay": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/hero_definitions/Hector.json b/simulator/config/hero_definitions/Hector.json--- a/simulator/config/hero_definitions/Hector.json+++ b/simulator/config/hero_definitions/Hector.json@@ -26,8 +26,9 @@50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -55,8 +56,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 10+ "attacks": {+ "count": 10+ }},"value_evolution": {"type": "pct_decay",@@ -80,8 +82,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 10+ "attacks": {+ "count": 10+ }},"value_evolution": {"type": "pct_decay",@@ -118,8 +121,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/hero_definitions/Jeronimo.json b/simulator/config/hero_definitions/Jeronimo.json--- a/simulator/config/hero_definitions/Jeronimo.json+++ b/simulator/config/hero_definitions/Jeronimo.json@@ -60,8 +60,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}}}diff --git a/simulator/config/hero_definitions/Lynn.json b/simulator/config/hero_definitions/Lynn.json--- a/simulator/config/hero_definitions/Lynn.json+++ b/simulator/config/hero_definitions/Lynn.json@@ -26,8 +26,9 @@50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Mia.json b/simulator/config/hero_definitions/Mia.json--- a/simulator/config/hero_definitions/Mia.json+++ b/simulator/config/hero_definitions/Mia.json@@ -29,8 +29,9 @@"applies_to": "target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -63,8 +64,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [@@ -99,8 +101,9 @@50],"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Molly.json b/simulator/config/hero_definitions/Molly.json--- a/simulator/config/hero_definitions/Molly.json+++ b/simulator/config/hero_definitions/Molly.json@@ -29,8 +29,9 @@"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -62,8 +63,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/hero_definitions/Natalia.json b/simulator/config/hero_definitions/Natalia.json--- a/simulator/config/hero_definitions/Natalia.json+++ b/simulator/config/hero_definitions/Natalia.json@@ -29,8 +29,9 @@"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}diff --git a/simulator/config/hero_definitions/Norah.json b/simulator/config/hero_definitions/Norah.json--- a/simulator/config/hero_definitions/Norah.json+++ b/simulator/config/hero_definitions/Norah.json@@ -74,8 +74,9 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [@@ -109,8 +110,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 2+ "turns": {+ "count": 2+ }}},"Momentum/2": {@@ -123,9 +125,10 @@25],"duration": {- "type": "turn",- "value": 2,- "delay": 1+ "turns": {+ "count": 2,+ "delay": 1+ }}}}diff --git a/simulator/config/hero_definitions/Philly.json b/simulator/config/hero_definitions/Philly.json--- a/simulator/config/hero_definitions/Philly.json+++ b/simulator/config/hero_definitions/Philly.json@@ -58,8 +58,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -96,8 +97,9 @@"applies_to": "all"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}diff --git a/simulator/config/hero_definitions/Reina.json b/simulator/config/hero_definitions/Reina.json--- a/simulator/config/hero_definitions/Reina.json+++ b/simulator/config/hero_definitions/Reina.json@@ -48,8 +48,9 @@"applies_to": "trigger"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -84,8 +85,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -25,9 +25,13 @@"applies_vs": "lancer"},"duration": {- "type": "attack",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ },+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max"}@@ -55,9 +59,10 @@"applies_vs": "lancer"},"duration": {- "type": "turn",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ }},"same_effect_stacking": "max"}@@ -85,9 +90,10 @@"applies_vs": "any"},"duration": {- "type": "turn",- "value": 1,- "delay": 1+ "turns": {+ "count": 1,+ "delay": 1+ }},"same_effect_stacking": "max"}diff --git a/simulator/config/hero_definitions/Wayne.json b/simulator/config/hero_definitions/Wayne.json--- a/simulator/config/hero_definitions/Wayne.json+++ b/simulator/config/hero_definitions/Wayne.json@@ -25,8 +25,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"same_effect_stacking": "max","trigger_damage_jobs": [@@ -60,8 +61,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -86,8 +88,9 @@"applies_vs": "any"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -127,8 +130,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/config/troop_skills.json b/simulator/config/troop_skills.json--- a/simulator/config/troop_skills.json+++ b/simulator/config/troop_skills.json@@ -97,8 +97,9 @@"applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -206,8 +207,9 @@]},"duration": {- "type": "turn",- "value": 1+ "turns": {+ "count": 1+ }}}}@@ -249,8 +251,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -290,8 +293,9 @@"applies_vs": "trigger.source"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }}}}@@ -354,8 +358,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -403,8 +408,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{@@ -470,8 +476,9 @@"applies_vs": "trigger.target"},"duration": {- "type": "attack",- "value": 1+ "attacks": {+ "count": 1+ }},"trigger_damage_jobs": [{diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts--- a/simulator/src/classifierDamage.test.ts+++ b/simulator/src/classifierDamage.test.ts@@ -101,7 +101,9 @@}const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);removeStaticProfileBucketEffects(effectIndex);- return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();+ const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };}test("classifier routes up/down effects into neutral atomic buckets", () => {@@ -412,7 +414,7 @@assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);});-test("attack-duration bucket effects are consumed by the applicable attack job", () => {+test("attack-duration bucket effects are charged by the applicable attack job", () => {const oneAttackEffect = {...effect("active.hero.attack.up", "attacker", 100),id: "attack-up-active",@@ -421,11 +423,11 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });- assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);});-test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {const turnEffect = {...effect("active.hero.defense.down", "defender", 30),id: "bad-luck-like",@@ -435,10 +437,11 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });- assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);- assert.ok(outcome.appliedEffectIds?.includes("BadLuckStreak/1"));+ assert.ok(outcome.usedEffectIds.includes("bad-luck-like"));assert.deepEqual(outcome.appliedEffects, [{+ kind: "modifier",+ activeEffectId: "bad-luck-like",effectId: "BadLuckStreak/1",bucket: "active.hero.defense.down",valuePct: 30,@@ -449,7 +452,7 @@]);});-test("attack-duration effects are only consumed when they participate in the calculation", () => {+test("effects are only charged when they participate in the calculation", () => {const defenderOutgoingBuff = {...effect("active.hero.lethality.up", "defender", 100),id: "defender-outgoing-buff",@@ -459,7 +462,7 @@const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);- assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);});test("pct attack value evolution uses the effect use count for the current bucket value", () => {@@ -481,7 +484,7 @@assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);- assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));});test("pct turn value evolution starts decaying after the first active turn", () => {@@ -506,7 +509,7 @@assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);});-test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {const weaker = {...effect("active.hero.lethality.up", "attacker", 50),id: "max-weaker",@@ -533,7 +536,7 @@assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);- assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));});test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {diff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts--- a/simulator/src/config.test.ts+++ b/simulator/src/config.test.ts@@ -71,6 +71,17 @@assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);});+test("loadSimulatorConfig rejects legacy duration shape", () => {+ const root = writeConfigWithTroopEffect({+ type: "active.hero.lethality.up",+ value: 10,+ units: { applies_to: "trigger.source", applies_vs: "target" },+ duration: { type: "attack", value: 1 } as never+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /duration key type.*not supported/i);+});+test("simulator config source does not reference legacy effect metadata names", () => {const legacyEffectMetadataKey = ["effect", "op"].join("_");const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");@@ -166,7 +177,7 @@{type: "passive.attack.up",value: 10,- duration: { type: "round", value: 1 }+ duration: { turns: { count: 1 } }},{ type: "battle_start" });diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -173,6 +173,7 @@validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);+ validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);if (!KNOWN_EFFECT_TYPES.has(type)) {@@ -263,6 +264,45 @@}}+function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {+ if (effect.duration === undefined) return;+ const path = `${file}:${skillId}.${effectId}.duration`;+ const duration = effect.duration as Record<string, unknown>;+ for (const key of Object.keys(duration)) {+ if (key !== "turns" && key !== "rounds" && key !== "attacks") {+ throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);+ }+ }+ for (const key of ["turns", "rounds", "attacks"] as const) {+ const value = duration[key];+ if (value === undefined) continue;+ validateNativeEffectDurationAxis(value, `${path}.${key}`);+ }+}++function validateNativeEffectDurationAxis(value: unknown, path: string): void {+ if (!value || typeof value !== "object" || Array.isArray(value)) {+ throw new Error(`native effect duration axis must be an object at ${path}`);+ }+ const axis = value as Record<string, unknown>;+ for (const key of Object.keys(axis)) {+ if (key !== "count" && key !== "delay") {+ throw new Error(`native effect duration axis key ${key} is not supported at ${path}; use count and/or delay`);+ }+ }+ if (axis.count === undefined && axis.delay === undefined) {+ throw new Error(`native effect duration axis must define count or delay at ${path}`);+ }+ if (axis.count !== undefined) validateIntegerRange(axis.count, 1, `${path}.count`);+ if (axis.delay !== undefined) validateIntegerRange(axis.delay, 0, `${path}.delay`);+}++function validateIntegerRange(value: unknown, min: number, path: string): void {+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {+ throw new Error(`native effect duration ${path} must be an integer >= ${min}`);+ }+}+function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {const path = `${file}:${skillId}.${effectId}`;if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -12,7 +12,7 @@import { UNIT_TYPES } from "./types";import { classifyEffectForJob } from "./classifier";import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";-import { currentEffectValuePct, isEffectActive } from "./effects";+import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";import {buildStaticDamageProfile,@@ -56,20 +56,17 @@export type DamageScratch = NumericDamageBuckets;-// Lean result of a single damage job: the correctness-relevant outputs (kills, consumed-// effect bookkeeping) plus, only when tracing, the recording detail. The heavy per-attack+// Lean result of a single damage job: kills plus, only when tracing, the recording detail.+// Usage charging happens via options.usedEffects (every mode); the heavy per-attack// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.export interface DamageResult {kills: number;- consumedEffectIds: string[];- appliedEffectIds?: string[];appliedEffects?: DamageEquationTrace["appliedEffects"];trace?: DamageEquationTrace;}const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};-const EMPTY_CONSUMED_EFFECT_IDS: string[] = [];const TROOPS_COUNT_TERM = factorTerm("troops.count");const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));@@ -109,7 +106,7 @@job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean }+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");// The damage math is one path; `trace` only decides whether we also capture the (expensive)@@ -128,9 +125,9 @@applyBucketValue(buckets, "troops.count", armyTerm);applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);- const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];- const consumedEffectIds = new Set<string>();+ const appliedEffects: DamageEquationTrace["appliedEffects"] = [];+ const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();const candidates: BucketCandidate[] = [];const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {@@ -158,7 +155,7 @@}}- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);@@ -184,12 +181,8 @@}: undefined;- const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];-return {kills,- consumedEffectIds: returnedConsumedEffectIds,- appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,appliedEffects: traceEnabled ? appliedEffects : undefined,trace};@@ -201,7 +194,7 @@detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;for (const candidate of candidates) {@@ -216,12 +209,12 @@maxGroups.set(key, { selected: candidate, candidates: [candidate] });}} else {- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);}}@@ -246,6 +239,8 @@);if (appliedValuePct !== 0 && detail === "full") {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {+ kind: "modifier",+ activeEffectId: selected.effect.id,effectId: selected.effect.source.effectId ?? selected.effect.id,bucket: selected.bucket,valuePct: appliedValuePct,@@ -267,11 +262,11 @@detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);if (appliedValuePct !== 0) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ usedEffects.add(candidate.effect);} else if (detail === "full") {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });}@@ -284,12 +279,13 @@detail: DamageDetail,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],- consumedEffectIds: Set<string>+ usedEffects: Set<ActiveEffect>): void {const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);if (appliedValuePct !== 0) {+ // The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.for (const candidate of candidates) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ usedEffects.add(candidate.effect);if (detail === "full" && candidate !== selected) {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });}@@ -355,6 +351,8 @@if (!bucket.startsWith("passive.") || !term) continue;for (const contributor of term.contributors) {appliedEffects.push({+ kind: "modifier",+ activeEffectId: contributor.effectId,effectId: contributor.effectId,bucket,valuePct: contributor.valuePct,@@ -560,6 +558,3 @@return Number(((factor - 1) * 100).toFixed(12));}-function sourceLabel(effect: ActiveEffect): string {- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");-}diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -14,6 +14,7 @@import { normalizeUnitType } from "./normalize";export type Rng = () => number;+type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];interface CompiledTriggerSelectors {source: ParsedTriggerSelector;@@ -127,11 +128,17 @@return skill.heroName ?? skill.troopType ?? "global";}+export function sourceLabel(effect: ActiveEffect): string {+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");+}+export function isEffectActive(effect: ActiveEffect, round: number): boolean {if (round < effect.startRound) return false;- if (effect.duration.type === "battle") return true;- if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);- return effect.uses < Math.max(1, effect.duration.value);+ return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));+}++export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {+ return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");}export function currentEffectValuePct(effect: ActiveEffect, round: number): number {@@ -229,13 +236,44 @@function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {if (!duration) return { type: "battle", value: 0 };- const rawType = duration.type === "turn" ? "round" : duration.type;- if (rawType === "round" || rawType === "attack" || rawType === "battle") {- return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 0) };- }+ const namedConstraints = normalizeNamedDurationConstraints(duration);+ if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };return { type: "battle", value: 0 };}+function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {+ return duration.constraints ?? [{ type: duration.type, count: duration.value }];+}++function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {+ const delay = Math.max(0, constraint.delay ?? 0);+ if (constraint.type === "battle") return true;+ if (constraint.type === "round") {+ const startRound = effect.createdRound + delay;+ if (round < startRound) return false;+ if (constraint.count === undefined) return true;+ return round < startRound + Math.max(1, constraint.count);+ }+ if (constraint.count === undefined) return true;+ return effect.uses < delay + Math.max(1, constraint.count);+}++function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {+ const constraints: EffectDurationConstraint[] = [];+ const turns = duration.turns ?? duration.rounds;+ if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));+ if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));+ return constraints;+}++function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {+ return {+ type,+ ...(value.count === undefined ? {} : { count: Number(value.count) }),+ ...(value.delay === undefined ? {} : { delay: Number(value.delay) })+ };+}+function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {return value === "max" ? "max" : "add";}diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts--- a/simulator/src/recorder.ts+++ b/simulator/src/recorder.ts@@ -1,4 +1,5 @@import type {+ AppliedEffect,AttackIntent,AttackOutcome,BattleTrace,@@ -11,7 +12,7 @@/*** Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop- * does all simulation-affecting work itself (kills, counters, effect consumption); the recorder+ * does all simulation-affecting work itself (kills, counters, effect usage charging); the recorder* only records, so swapping recorders never changes the outcome. This keeps a single, linear* loop with no `if (mode === ...)` branches scattered through it.*@@ -22,14 +23,15 @@export interface BattleRecorder {/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */readonly capturesTrace: boolean;- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void;- recordDamageJob(job: DamageJob, result: DamageResult): void;+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;readonly attacks: AttackOutcome[];readonly trace: BattleTrace | undefined;}const NO_ATTACKS: AttackOutcome[] = [];+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];export const NULL_RECORDER: BattleRecorder = {capturesTrace: false,@@ -62,7 +64,7 @@this.capturesTrace = trace !== undefined;}- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {this.attacks.push({jobId: `${intent.id}:cancelled`,kind: "normal",@@ -75,15 +77,13 @@{ side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },{ side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }],- appliedEffectIds: [],- appliedEffects: [],- consumedEffectIds,+ appliedEffects,cancelledBy: effectId,cancelReason: reason});}- recordDamageJob(job: DamageJob, result: DamageResult): void {+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void {if (job.kind === "skill" && job.sourceSkillReportKey && result.kills > 0) {const report = this.skillReports[job.attackerSide].get(job.sourceSkillReportKey);if (report) report.skillKills += result.kills;@@ -103,9 +103,7 @@{ side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },{ side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }],- appliedEffectIds: result.appliedEffectIds ?? [],- appliedEffects: result.appliedEffects ?? [],- consumedEffectIds: result.consumedEffectIds,+ appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),trace: result.trace});}@@ -114,3 +112,9 @@this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });}}++function mergeAppliedEffects(applied: AppliedEffect[] | undefined, extra: AppliedEffect[] | undefined): AppliedEffect[] {+ if (!applied?.length) return extra?.length ? extra : NO_APPLIED_EFFECTS;+ if (!extra?.length) return applied;+ return [...applied, ...extra];+}diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -7,7 +7,7 @@import { createSeededRng, chancePasses } from "./effects";import { applyHeroGenerationStats, resolveFighter } from "./resolve";import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";+import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";test("runPrepared reuses the compiled input seed when no override is supplied", () => {const config = loadSimulatorConfig();@@ -257,7 +257,7 @@type: "active.hero.lethality.up",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}@@ -296,7 +296,7 @@type: "active.hero.defense.up",value: 100,units: { applies_to: "target", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -311,6 +311,60 @@assert.equal(report?.effectActivations, 2);});+test("attack-duration effects with a round cap expire at the end of their active turn even when unused", () => {+ const result = simulateBattle(+ {+ maxRounds: 4,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DreamLancer: {+ name: "DreamLancer",+ troop_type: "lancer",+ skills: {+ EvenTurnAmbusher: {+ trigger: { type: "turn", every: 2 },+ effects: {+ order: {+ type: "attack_order",+ value: ["marksman", "infantry", "lancer"],+ units: { applies_to: "lancer", applies_vs: "marksman" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ NightmareTrace: {+ trigger: { type: "turn", every: 2, source: "lancer" },+ effects: {+ mark: {+ type: "active.hero.defense.down",+ value: 100,+ units: { applies_to: "target", applies_vs: "lancer" },+ duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");+ const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));+ assert.equal(round4LancerAttack?.defenderUnit, "marksman");+ assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);+});+test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {const config = loadSimulatorConfig();const result = simulateBattle(@@ -416,7 +470,7 @@type: "active.hero.lethality.up",value: 10,units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },- duration: { type: "battle", value: 1 },+ duration: {},same_effect_stacking: "add"}}@@ -472,7 +526,7 @@assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });});-test("attack-duration effects are consumed even when a normal attack is cancelled", () => {+test("cancelled attacks report the winning control as an applied effect", () => {const result = simulateBattle({maxRounds: 1,@@ -496,33 +550,89 @@type: "no_attack",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},debuff: {type: "active.hero.attack.up",value: 100,units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}}}- })+ }),+ { mode: "trace" });const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);const cancelledAttack = cancelled!;- assert.equal(cancelledAttack.consumedEffectIds.length, 2);- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));+ assert.equal(cancelledAttack.appliedEffects.length, 1);+ const controlEvent = cancelledAttack.appliedEffects[0];+ assert.equal(controlEvent.kind, "control");+ assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");+ assert.ok(controlEvent.activeEffectId.includes(":cancel:"));assert.deepEqual(cancelledAttack.counterDeltas, [{ side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }]);});+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {+ // Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is+ // active. By default the cancelled attack charges the buff, so round 2 lands without it;+ // with useEffectsOnNoAttack:false the buff survives to boost round 2.+ const input = {+ maxRounds: 2,+ seed: "cancel-charge",+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: { SelfStunned: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ }+ };+ const config = minimalConfig({+ SelfStunned: {+ name: "SelfStunned",+ skills: {+ StunAndBuff: {+ trigger: { type: "battle_start" },+ effects: {+ stun: {+ type: "no_attack",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1, delay: 1 } }+ },+ buff: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ });++ const roundTwoKills = (options: SimulationOptions): number => {+ const result = simulateBattle(input, config, options);+ const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));+ assert.notEqual(attack, undefined);+ return attack!.kills;+ };++ const charged = roundTwoKills({});+ const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });+ assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);+});+test("cancelled normal attacks advance attack counters for later frequency checks", () => {const result = simulateBattle({@@ -546,7 +656,7 @@cancel: {type: "no_attack",units: { applies_to: "trigger", applies_vs: "target" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -637,7 +747,7 @@same_effect_stacking: "max",units: { applies_to: ["infantry"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -651,9 +761,11 @@const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));assert.notEqual(roundTwoNormalOutcome, undefined);- assert.equal(roundTwoNormalOutcome!.consumedEffectIds.length, 2);+ const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");+ assert.equal(extraAttackEvents.length, 1);+ assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);assert.notEqual(roundTwoSkillOutcome, undefined);- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);+ assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);});test("requires_effect is ignored by native simulator effect activation", () => {@@ -767,14 +879,14 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},hitMarksman: {type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -819,7 +931,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -861,7 +973,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -877,7 +989,7 @@type: "active.hero.defense.up",value: 50,units: { applies_to: "trigger.target", applies_vs: "trigger.source" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -919,14 +1031,14 @@value: 500,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }},second: {type: "extra_skill_attack",value: 500,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1022,7 +1134,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source" } as never],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1061,7 +1173,7 @@type: "extra_skill_attack",value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1098,7 +1210,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1145,7 +1257,7 @@value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1161,7 +1273,7 @@type: "no_attack",value: 100,units: { applies_to: "enemy.marksman", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1173,7 +1285,7 @@const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);- assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);+ assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];assert.deepEqual(skillJobsByRound, [0, 1]);@@ -1205,7 +1317,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1248,7 +1360,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}},@@ -1260,7 +1372,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1304,7 +1416,7 @@value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1382,7 +1494,7 @@value: 100,units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1422,7 +1534,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: ["lancer"] },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1465,7 +1577,7 @@value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}},@@ -1476,7 +1588,7 @@type: "type.skill.damage.up",value: 100,units: { applies_to: ["marksman"], applies_vs: "any" },- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1519,7 +1631,7 @@type: "active.hero.lethality.down",value: 50,units: { applies_to: "trigger.source", applies_vs: "trigger.target" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}@@ -1562,7 +1674,7 @@type: "active.hero.lethality.down",value: 50,units: { applies_to: "trigger.target", applies_vs: "any" },- duration: { type: "turn", value: 1 }+ duration: { turns: { count: 1 } }}}}@@ -1779,7 +1891,7 @@value: 100,units: { applies_to: "trigger.source", applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "attack", value: 2 }+ duration: { attacks: { count: 2 } }}}}@@ -1794,7 +1906,7 @@pause: {type: "no_attack",units: { applies_to: "enemy.marksman", applies_vs: "any" },- duration: { type: "attack", value: 1 }+ duration: { attacks: { count: 1 } }}}}@@ -1938,14 +2050,14 @@same_effect_stacking,units: { applies_to: ["infantry"], applies_vs: "any" },trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],- duration: { type: "turn", value: 2 }+ duration: { turns: { count: 2 } }}: {type,value: 100,same_effect_stacking,units: { applies_to: ["infantry"], applies_vs: "any" },- duration: { type: "turn", value: 2 }+ duration: { turns: { count: 2 } }};return minimalConfig({[heroName]: {diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -1,5 +1,9 @@import type {ActiveEffect,+ AppliedControlEffect,+ AppliedEffect,+ AppliedExtraAttackEffect,+ AppliedOrderEffect,AttackIntent,AttackOutcome,BearBattleResult,@@ -27,11 +31,13 @@chancePasses,createSeededRng,currentEffectValuePct,+ hasAttackDurationConstraint,isEffectActive,oppositeSide,parseTriggerSelector,sideForTriggerRelation,skillMatchesTrigger,+ sourceLabel,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";@@ -49,7 +55,9 @@interface Runtime {activeEffects: ActiveEffect[];effectIndex: EffectIndex;- effectById: Map<string, ActiveEffect>;+ // Per-job scratch: effects that affected the job being calculated; drained by+ // chargeUsedEffects (uses += 1 each) after every job in every mode.+ usedEffects: Set<ActiveEffect>;staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;@@ -80,7 +88,8 @@interface ExtraSkillJobsResult {jobs: DamageJob[];- consumedEffectIds: string[];+ usedEffects: ActiveEffect[];+ appliedEffects?: AppliedExtraAttackEffect[];}interface BattleRun {@@ -222,7 +231,7 @@return {activeEffects,effectIndex,- effectById: new Map(activeEffects.map((e) => [e.id, e])),+ usedEffects: new Set(),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,@@ -299,6 +308,10 @@const mode = options.mode ?? "standard";const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;+ const useEffectsOnCancel = {+ dodge: options.useEffectsOnDodge ?? true,+ no_attack: options.useEffectsOnNoAttack ?? true+ };let rounds = 0;let score = 0;@@ -309,7 +322,9 @@expireInactive(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);- const intents = resolveAttackIntents(round, runtime, roundStartTroops);+ // Trace-only: battle_order applied events keyed by the intent they ordered.+ const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);const allJobs: DamageJob[] = []; // for recorderconst results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];@@ -324,9 +339,15 @@if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;runtime.attackControlCounts[control.reason] += 1;- const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);- cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });- consumeEffects(runtime, consumedEffectIds);+ if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);+ cancelled.push({+ intent,+ effectId: control.effect.id,+ reason: control.reason,+ appliedEffects: recorder.capturesTrace+ ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))+ : NO_APPLIED_EFFECTS+ });} else {pendingNormalJobs.push(job);}@@ -334,7 +355,7 @@// Phase 2: calculate each normal job; any extra_skill_attack effect it// uses spawns extra DamageJobs that are calculated immediately after.- // consumeEffects runs after each job so subsequent normal jobs see the+ // chargeUsedEffects runs after each job so subsequent normal jobs see the// correct uses count on any shared extra_skill_attack effects.for (const job of pendingNormalJobs) {allJobs.push(job);@@ -343,19 +364,21 @@effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects});- results.push({ job, result: normalResult });if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}- consumeEffects(runtime, normalResult.consumedEffectIds);-- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);- if (extraSkill.consumedEffectIds.length > 0) {- normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];- consumeEffects(runtime, extraSkill.consumedEffectIds);- }+ chargeUsedEffects(runtime);++ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);+ for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;+ results.push({+ job,+ result: normalResult,+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)+ });for (const extraJob of extraSkill.jobs) {allJobs.push(extraJob);@@ -364,13 +387,14 @@effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,- capToDefenderTroops: loopOptions.capJobKills+ capToDefenderTroops: loopOptions.capJobKills,+ usedEffects: runtime.usedEffects});results.push({ job: extraJob, result: extraResult });if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {score += extraResult.kills;}- consumeEffects(runtime, extraResult.consumedEffectIds);+ chargeUsedEffects(runtime);}}@@ -378,8 +402,8 @@if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);else commitRoundCounters(cancelled, results, runtime);- for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.consumedEffectIds);- for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.extraAppliedEffects);recorder.recordRound(round, roundStartTroops, intents, allJobs);}@@ -399,12 +423,37 @@intent: AttackIntent;effectId: string;reason: "dodge" | "no_attack";- consumedEffectIds: string[];+ appliedEffects: AppliedEffect[];}interface DamageJobResult {job: DamageJob;result: DamageResult;+ extraAppliedEffects?: AppliedEffect[];+}++const NO_APPLIED_EFFECTS: AppliedEffect[] = [];++function appendedEvent(order: AppliedOrderEffect | undefined, event: AppliedEffect): AppliedEffect[] {+ return order ? [order, event] : [event];+}++function appendedEvents(order: AppliedOrderEffect | undefined, events: AppliedEffect[] | undefined): AppliedEffect[] | undefined {+ if (!order) return events;+ return events ? [order, ...events] : [order];+}++function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {+ return {+ activeEffectId: effect.id,+ effectId: effect.source.effectId ?? effect.id,+ source: sourceLabel(effect),+ sourceSide: effect.ownerSide+ };+}++function appliedControlEvent(control: Control): AppliedControlEffect {+ return { kind: "control", ...appliedEffectBase(control.effect), reason: control.reason };}function triggerRoundStartSkills(@@ -529,7 +578,7 @@return {activeEffects: [],effectIndex: createEffectIndex(),- effectById: new Map(),+ usedEffects: new Set(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,@@ -562,7 +611,6 @@function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {runtime.activeEffects.push(effect);- runtime.effectById.set(effect.id, effect);indexEffect(runtime.effectIndex, effect);}@@ -641,7 +689,8 @@function resolveAttackIntents(round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ orderEvents?: Map<string, AppliedOrderEffect>): AttackIntent[] {const intents: AttackIntent[] = [];for (const side of ["attacker", "defender"] as SideId[]) {@@ -649,12 +698,18 @@let orderIndex = 0;for (const attackerUnit of UNIT_TYPES) {if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);+ const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);if (!defenderUnit) continue;+ const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;+ if (ordered) {+ ordered.effect.uses += 1;+ orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });+ }const previousAttackCount = runtime.counters.attacks[side][attackerUnit];const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];intents.push({- id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,+ id: intentId,round,source: "normal",attackerSide: side,@@ -673,6 +728,8 @@return intents;}+// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but+// does not charge them: no real attack intent is being ordered.function chooseDefenderUnit(attackerUnit: UnitType,attackerSide: SideId,@@ -681,17 +738,26 @@effectIndex: EffectIndex,round: number): UnitType | undefined {- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;- return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;+ return firstLivingUnit(order, defenderSide, roundStartTroops);}-function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {+function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {+ return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);+}++function orderFromEffects(+ attackerUnit: UnitType,+ attackerSide: SideId,+ index: EffectIndex,+ round: number+): { order: UnitType[]; effect: ActiveEffect } | undefined {for (const effect of index.battleOrder) {if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;if (Array.isArray(effect.intent.value)) {try {- return effect.intent.value.map((value) => normalizeUnitType(String(value)));+ return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };} catch {return undefined;}@@ -740,10 +806,12 @@normalAttack: DamageJob,round: number,runtime: Runtime,- roundStartTroops: DamageJob["roundStartTroops"]+ roundStartTroops: DamageJob["roundStartTroops"],+ capturesTrace: boolean): ExtraSkillJobsResult {const jobs: DamageJob[] = [];- const consumedEffectIds: string[] = [];+ const usedEffects: ActiveEffect[] = [];+ let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round@@ -784,10 +852,19 @@definitionIndex += 1;}if (jobs.length > firstJobIndex) {- consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));+ // The whole stacking group is charged whenever the selected effect spawned jobs,+ // regardless of duration constraints (extra attacks always deplete per firing).+ usedEffects.push(...effectGroup.effects);+ if (capturesTrace) {+ (appliedEffects ??= []).push({+ kind: "extra_attack",+ ...appliedEffectBase(effect),+ spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)+ });+ }}}- return { jobs, consumedEffectIds };+ return { jobs, usedEffects, appliedEffects };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {@@ -926,24 +1003,28 @@}}-function attackDurationEffectIdsForJob(job: DamageJob, index: EffectIndex): string[] {- const ids: string[] = [];- for (const candidate of bucketCandidatesForJob(index, job)) {- if (candidate.effect.duration.type === "attack") ids.push(candidate.effect.id);- }- for (const effect of index.controls) {- if (effect.duration.type !== "attack") continue;- const classification = classifyEffectForJob(effect, job);- if (classification?.kind === "control") ids.push(effect.id);- }- return ids;+// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in+// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.+function chargeUsedEffects(runtime: Runtime): void {+ if (runtime.usedEffects.size === 0) return;+ for (const effect of runtime.usedEffects) effect.uses += 1;+ runtime.usedEffects.clear();}-function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {- for (const consumed of consumedEffectIds) {- const effect = runtime.effectById.get(consumed);- if (effect) effect.uses += 1;+// A cancelled attack still charges the attacker's attack-constrained effects (the attack+// happened, it just didn't land) plus the control that cancelled it, whatever its duration.+function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {+ const used = runtime.usedEffects;+ for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {+ if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);+ }+ for (const effect of runtime.effectIndex.controls) {+ if (!hasAttackDurationConstraint(effect)) continue;+ const classification = classifyEffectForJob(effect, job);+ if (classification?.kind === "control") used.add(effect);}+ used.add(winningControl);+ chargeUsedEffects(runtime);}function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts--- a/simulator/src/staticDamageProfile.ts+++ b/simulator/src/staticDamageProfile.ts@@ -1,7 +1,7 @@import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";-import { currentEffectValuePct } from "./effects";+import { currentEffectValuePct, sourceLabel } from "./effects";export interface StaticDamageProfileTerm {raw?: number;@@ -90,13 +90,9 @@const duration = effect.duration;if (duration === undefined) return;- const durationType = duration.type ?? "battle";- if (durationType !== "battle") {+ if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);}- if (duration.delay !== undefined && duration.delay !== 0) {- throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);- }}export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {@@ -266,9 +262,6 @@return { attack: 0, defense: 0, lethality: 0, health: 0 };}-function sourceLabel(effect: ActiveEffect): string {- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");-}const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -78,6 +78,7 @@type: "battle" | "round" | "attack";value: number;delay?: number;+ constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;}export interface EffectIntentDefinition {@@ -87,7 +88,11 @@value_evolution?: { type?: string; step?: string; value?: number };units?: Record<string, unknown>;trigger_damage_jobs?: TriggerDamageJobDefinition[];- duration?: { type?: string; value?: number; delay?: number };+ duration?: {+ turns?: { count?: number; delay?: number };+ rounds?: { count?: number; delay?: number };+ attacks?: { count?: number; delay?: number };+ };same_effect_stacking?: SameEffectStacking;reason?: string;}@@ -172,6 +177,10 @@export interface SimulationOptions {mode?: SimulationMode;+ // Whether a dodged / no_attack'd attack still charges (uses += 1) the attacker's+ // attack-constrained effects, as the game does. Default true.+ useEffectsOnDodge?: boolean;+ useEffectsOnNoAttack?: boolean;}export interface ResolvedTroopLine {@@ -245,6 +254,10 @@createdRound: number;startRound: number;duration: EffectDuration;+ // Times this instance affected battle mechanics (damage bucket applied, control fired,+ // attack ordered, extra attack spawned). Attack duration constraints and step:"attack"+ // value evolution read it; cancelled attacks still charge attack-constrained effects+ // unless useEffectsOnDodge/useEffectsOnNoAttack disable that.uses: number;stackingKey?: string;sameEffectStacking: SameEffectStacking;@@ -310,22 +323,49 @@armyTerm: number;atomicBuckets: Record<string, DamageBucketTrace>;aggregationGroups: Record<string, DamageAggregationGroupTrace>;- appliedEffects: AppliedEffectTrace[];+ appliedEffects: AppliedModifierEffect[];rejectedEffects: Array<{ effectId: string; reason: string }>;rawDamage: number;finalKills: number;}-export interface AppliedEffectTrace {+// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.+// Events are built only in trace mode; the uses counter is charged in every mode.+interface AppliedEffectBase {+ // ActiveEffect.id — the runtime instance. Static-profile/input-stat contributors have no+ // ActiveEffect and use the config-level effectId here too.+ activeEffectId: string;+ // Config-level id (source.effectId ?? ActiveEffect.id).effectId: string;- bucket: string;- valuePct: number;source: string;sourceSide?: SideId;+}++export interface AppliedModifierEffect extends AppliedEffectBase {+ kind: "modifier";+ bucket: string;+ valuePct: number;stackingKey?: string;sameEffectStacking?: SameEffectStacking;}+export interface AppliedControlEffect extends AppliedEffectBase {+ kind: "control";+ reason: "dodge" | "no_attack";+}++export interface AppliedOrderEffect extends AppliedEffectBase {+ kind: "battle_order";+ chosenTarget: UnitType;+}++export interface AppliedExtraAttackEffect extends AppliedEffectBase {+ kind: "extra_attack";+ spawnedJobIds: string[];+}++export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;+export interface AttackOutcome {jobId: string;kind: DamageKind;@@ -337,9 +377,7 @@defenderUnit: UnitType;kills: number;counterDeltas: CounterDelta[];- appliedEffectIds: string[];- appliedEffects: AppliedEffectTrace[];- consumedEffectIds: string[];+ appliedEffects: AppliedEffect[];cancelledBy?: string;cancelReason?: "dodge" | "no_attack";trace?: DamageEquationTrace;
Run B dirty state patch
diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json--- a/simulator/config/hero_definitions/Ahmose.json+++ b/simulator/config/hero_definitions/Ahmose.json@@ -7,7 +7,8 @@"description": "His infantry pauses the attack once every 4 times reducing damage taken by Lancers and Marksmen by X% and Infantry by X% for 2 turns","trigger": {"type": "attack",- "every": 4,+ "first": 4,+ "every": 5,"source": "infantry"},"effects": {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@@ -32,10 +32,12 @@}},"AirDominance": {- "description": "Grants all troops' attack X% extra damage after every 4 attacks and causes the target to receive X% extra damage for its next attack received",+ "description": "Grants all troops' attack X% extra damage after every 5 attacks and causes the target to receive X% extra damage for its next attack received","trigger": {- "type": "attack",- "every": 4+ "type": "turn",+ "first": 5,+ "every": 6,+ "source": "self.all"},"effects": {"AirDominance/1": {@@ -49,7 +51,7 @@],"units": {"applies_to": "trigger.source",- "applies_vs": "trigger.target"+ "applies_vs":"trigger.target"},"duration": {"attacks": {@@ -59,7 +61,7 @@"trigger_damage_jobs": [{"source": "use.source",- "target": "effect.applies_vs"+ "target": "use.target"}]},@@ -73,12 +75,16 @@15],"units": {- "applies_to": "target"+ "applies_to": "trigger.target"},+ "same_effect_stacking": "max","duration": {- "attacks": {+ "turns": {"count": 1,"delay": 1+ },+ "attacks": {+ "count": 1}}}diff --git a/simulator/config/hero_definitions/Reina.json b/simulator/config/hero_definitions/Reina.json--- a/simulator/config/hero_definitions/Reina.json+++ b/simulator/config/hero_definitions/Reina.json@@ -45,7 +45,8 @@"SwiftJive/1": {"type": "dodge","units": {- "applies_to": "trigger"+ "applies_to": "target",+ "applies_vs": "trigger.source"},"duration": {"attacks": {diff --git a/simulator/src/classifier.ts b/simulator/src/classifier.ts--- a/simulator/src/classifier.ts+++ b/simulator/src/classifier.ts@@ -11,10 +11,13 @@}export function classifyEffectForJob(effect: ActiveEffect, job: DamageJob): Classification | undefined {- if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };-const type = effect.intent.type;- if (type === "dodge" || type === "no_attack") return { kind: "control", control: type };+ if (type === "dodge" || type === "no_attack") {+ if (!controlEffectApplies(effect, job, type)) return { kind: "report_only", reason: "not_applicable_to_job" };+ return { kind: "control", control: type };+ }++ if (!basicEffectApplies(effect, job)) return { kind: "report_only", reason: "not_applicable_to_job" };if (type === "extra_skill_attack") return { kind: "extra_skill_attack" };if (type === "attack_order") return { kind: "battle_order" };@@ -41,6 +44,16 @@return true;}+function controlEffectApplies(effect: ActiveEffect, job: DamageJob, control: "dodge" | "no_attack"): boolean {+ const appliesToSide = control === "no_attack" ? job.attackerSide : job.defenderSide;+ const appliesToUnit = control === "no_attack" ? job.attackerUnit : job.defenderUnit;+ if (effect.appliesTo.side !== appliesToSide || !unitMaskHas(effect.appliesTo.units, appliesToUnit)) return false;++ const appliesVsSide = control === "no_attack" ? job.defenderSide : job.attackerSide;+ const appliesVsUnit = control === "no_attack" ? job.defenderUnit : job.attackerUnit;+ return effect.appliesVs.side === appliesVsSide && unitMaskHas(effect.appliesVs.units, appliesVsUnit);+}+function unsupportedReason(effect: ActiveEffect, job: DamageJob): string {if (effect.appliesTo.side === job.attackerSide) return "unsupported_attacker_effect";if (effect.appliesTo.side === job.defenderSide) return "unsupported_defender_effect";diff --git a/simulator/src/config.ts b/simulator/src/config.ts--- a/simulator/src/config.ts+++ b/simulator/src/config.ts@@ -215,6 +215,12 @@if (legacyUnits !== undefined) {throw new Error(`legacy trigger units filters are not supported at ${file}:${skillId}.trigger.units; use trigger.source and trigger.target`);}+ if (trigger.first !== undefined && (!Number.isFinite(Number(trigger.first)) || Number(trigger.first) < 1)) {+ throw new Error(`trigger.first must be a positive number at ${file}:${skillId}.trigger.first`);+ }+ if (trigger.first !== undefined && trigger.every === undefined) {+ throw new Error(`trigger.first requires trigger.every at ${file}:${skillId}.trigger`);+ }}function isTriggerRelativeUnitSelector(selector: unknown): selector is string {diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -106,12 +106,13 @@job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: 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?: Set<ActiveEffect> }): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");// The damage math is one path; `trace` only decides whether we also capture the (expensive)// per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.const traceEnabled = options.trace === true;+ const recordAppliedEffects = traceEnabled || options.recordAppliedEffects === true;const detail: DamageDetail = traceEnabled ? "full" : "fast";const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);const attacker = fighters[job.attackerSide];@@ -155,9 +156,9 @@}}- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);- if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);+ 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;@@ -183,7 +184,7 @@return {kills,- appliedEffects: traceEnabled ? appliedEffects : undefined,+ appliedEffects: recordAppliedEffects ? appliedEffects : undefined,trace};}@@ -192,6 +193,7 @@candidates: BucketCandidate[],buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>@@ -209,12 +211,12 @@maxGroups.set(key, { selected: candidate, candidates: [candidate] });}} else {- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);+ applyBucketCandidate(candidate, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}if (!maxGroups) return;for (const group of maxGroups.values()) {- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, recordAppliedEffects, appliedEffects, rejectedEffects, usedEffects);}}@@ -224,6 +226,7 @@selected: BucketCandidate,buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"]): number {const appliedValuePct = applyBucketValue(@@ -237,7 +240,7 @@selected.effect.stackingKey,selected.effect.sameEffectStacking);- if (appliedValuePct !== 0 && detail === "full") {+ if (appliedValuePct !== 0 && recordAppliedEffects) {const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {kind: "modifier",activeEffectId: selected.effect.id,@@ -260,11 +263,12 @@candidate: BucketCandidate,buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);+ const appliedValuePct = applySelectedBucket(candidate, buckets, detail, recordAppliedEffects, appliedEffects);if (appliedValuePct !== 0) {usedEffects.add(candidate.effect);} else if (detail === "full") {@@ -277,11 +281,12 @@candidates: BucketCandidate[],buckets: NumericDamageBuckets,detail: DamageDetail,+ recordAppliedEffects: boolean,appliedEffects: DamageEquationTrace["appliedEffects"],rejectedEffects: DamageEquationTrace["rejectedEffects"],usedEffects: Set<ActiveEffect>): void {- const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);+ const appliedValuePct = applySelectedBucket(selected, 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) {@@ -557,4 +562,3 @@function pctFromFactor(factor: number): number {return Number(((factor - 1) * 100).toFixed(12));}-diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts--- a/simulator/src/effects.ts+++ b/simulator/src/effects.ts@@ -37,8 +37,8 @@if (triggerType === "battle_start" && trigger.type !== "battle_start") return false;if (triggerType === "round_start" && trigger.type !== "turn") return false;if (triggerType === "attack_declared" && trigger.type !== "attack") return false;- if (trigger.every && triggerType === "round_start" && !crossedFrequency(round - 1, round, trigger.every)) return false;- if (trigger.every && triggerType === "attack_declared" && intent && !crossedFrequency(intent.previousAttackCount, intent.projectedAttackCount, trigger.every)) 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);return (@@ -76,8 +76,10 @@};}-export function crossedFrequency(previous: number, current: number, frequency: number): boolean {- return Math.floor(previous / frequency) < Math.floor(current / frequency);+export function crossedFrequency(previous: number, current: number, frequency: number, first = frequency): boolean {+ if (current < first) return false;+ 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 {diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts--- a/simulator/src/recorder.ts+++ b/simulator/src/recorder.ts@@ -23,6 +23,8 @@export interface BattleRecorder {/** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */readonly capturesTrace: boolean;+ /** When true the loop records lightweight "effect applied" events on each AttackOutcome. */+ readonly capturesAppliedEffects: boolean;recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;@@ -35,6 +37,7 @@export const NULL_RECORDER: BattleRecorder = {capturesTrace: false,+ capturesAppliedEffects: false,recordCancelled() {},recordDamageJob() {},recordRound() {},@@ -55,6 +58,7 @@readonly attacks: AttackOutcome[] = [];readonly trace: BattleTrace | undefined;readonly capturesTrace: boolean;+ readonly capturesAppliedEffects = true;constructor(private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,@@ -67,6 +71,7 @@recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {this.attacks.push({jobId: `${intent.id}:cancelled`,+ round: intent.round,kind: "normal",attackerSide: intent.attackerSide,attackerUnit: intent.attackerUnit,@@ -91,6 +96,7 @@const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";this.attacks.push({jobId: job.id,+ round: job.round,kind: job.kind,sourceEffectId: job.sourceEffectId,sourceSkillReportKey: job.sourceSkillReportKey,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@@ -62,6 +62,45 @@assert.ok(result.skillReport.attacker.some((entry) => entry.sourceKind === "troop_skill"));});+test("standard battle outcomes include round and applied effects without full traces", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: { Booster: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ }+ },+ minimalConfig({+ Booster: {+ name: "Booster",+ troop_type: "infantry",+ skills: {+ BattleBoost: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.attack.up",+ value: 25,+ units: { applies_to: "self.infantry", applies_vs: "any" }+ }+ }+ }+ }+ }+ })+ );++ const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");+ assert.equal(attack?.round, 1);+ assert.equal(attack?.appliedEffects.some((effect) => effect.effectId === "boost"), true);+ assert.equal(attack?.trace, undefined);+});+test("simulateBearBattle runs exactly 10 rounds and leaves the bear army unchanged", () => {const player: FighterInput = {name: "Player",@@ -662,6 +701,128 @@]);});+test("no_attack applies_to cancels that unit attacking, not attacks targeting that unit", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000, lancer_t1: 1000 },+ heroes: { StopInfantry: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000, lancer_t1: 1000 },+ heroes: {}+ }+ },+ minimalConfig({+ StopInfantry: {+ name: "StopInfantry",+ skills: {+ Pause: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ stop: {+ type: "no_attack",+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { turns: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:cancelled"]+ );+ assert.equal(+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.defenderSide === "attacker" && attack.defenderUnit === "infantry" && attack.cancelReason === "no_attack"),+ false+ );+});++test("dodge applies_to cancels attacks targeting that unit, not that unit attacking", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1000, lancer_t1: 1000 },+ heroes: { Dodger: { skill_1: 1 } }+ }+ },+ minimalConfig({+ Dodger: {+ name: "Dodger",+ skills: {+ StepAside: {+ trigger: { type: "attack", source: "enemy.infantry", target: "self.infantry" },+ effects: {+ evade: {+ type: "dodge",+ units: { applies_to: "target", applies_vs: "trigger" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:cancelled"]+ );+ assert.equal(+ result.attacks.some((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "infantry" && attack.cancelReason === "dodge"),+ false+ );+});++test("attack-declared controls from later intents affect earlier same-round attacks", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 1000 },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1000 },+ heroes: { ReactiveDodge: { skill_1: 1 } }+ }+ },+ minimalConfig({+ ReactiveDodge: {+ name: "ReactiveDodge",+ skills: {+ StepAside: {+ trigger: { type: "attack", source: "infantry" },+ effects: {+ evade: {+ type: "dodge",+ units: { applies_to: "self.infantry", applies_vs: "any" },+ duration: { turns: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "dodge").map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:cancelled"]+ );+});+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {// Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is// active. By default the cancelled attack charges the buff, so round 2 lands without it;@@ -753,6 +914,44 @@);});+test("attack frequency triggers can start at a different first threshold", () => {+ const result = simulateBattle(+ {+ maxRounds: 14,+ attacker: {+ troops: { infantry_t1: 10000 },+ heroes: { FirstThenEvery: { skill_1: 1 } }+ },+ defender: {+ troops: { infantry_t1: 10000 },+ heroes: {}+ }+ },+ minimalConfig({+ FirstThenEvery: {+ name: "FirstThenEvery",+ skills: {+ Pause: {+ trigger: { type: "attack", first: 4, every: 5, source: "infantry" },+ effects: {+ cancel: {+ type: "no_attack",+ units: { applies_to: "trigger", applies_vs: "target" },+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ })+ );++ assert.deepEqual(+ result.attacks.filter((attack) => attack.cancelReason === "no_attack").map((attack) => attack.jobId),+ ["r4:attacker:infantry:0:cancelled", "r9:attacker:infantry:0:cancelled", "r14:attacker:infantry:0:cancelled"]+ );+});+test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -322,19 +322,24 @@expireInactive(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);- // Trace-only: battle_order applied events keyed by the intent they ordered.- const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;+ // Applied-effect events keyed by the intent they ordered.+ const orderEvents = recorder.capturesAppliedEffects ? new Map<string, AppliedOrderEffect>() : undefined;const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);const allJobs: DamageJob[] = []; // for recorderconst results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];- // Phase 1: fire all attack_declared triggers — all ActiveEffects are- // resolved before any damage is calculated, per the battle-core spec.+ // Phase 1: fire all attack_declared triggers for every intended attack before+ // evaluating any controls or damage, per the battle-core spec.const pendingNormalJobs: DamageJob[] = [];+ const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);+ declaredNormalJobs.push({ intent, job });+ }++ for (const { intent, job } of declaredNormalJobs) {const controls = applicableControls(job, round, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;@@ -344,7 +349,7 @@intent,effectId: control.effect.id,reason: control.reason,- appliedEffects: recorder.capturesTrace+ appliedEffects: recorder.capturesAppliedEffects? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control)): NO_APPLIED_EFFECTS});@@ -361,6 +366,7 @@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,@@ -372,7 +378,7 @@}chargeUsedEffects(runtime);- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;results.push({job,@@ -384,6 +390,7 @@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,diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -100,6 +100,7 @@export interface TriggerDefinition {type: string;probability?: unknown;+ first?: number;every?: number;source?: unknown;target?: unknown;@@ -368,6 +369,7 @@export interface AttackOutcome {jobId: string;+ round: number;kind: DamageKind;sourceEffectId?: string;sourceSkillReportKey?: string;