← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.19%
Avg Error B0.19%
Δ Avg Error-0.00%
Improved1
Regressed0
Added0
Retired0
Testcase Delta
200 / 200
Code / Config Changes
Code Changes (Run A → Run B)
Index: testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json===================================================================--- testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json prev run+++ testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json this run@@ -79,76 +79,8 @@"marksman_t6": 2700},"stats": {"inf": {- "attack": 0.0,- "defense": 221.7,- "lethality": 147.3,- "health": 155.7- },- "lanc": {- "attack": 194.7,- "defense": 193.0,- "lethality": 157.3,- "health": 148.4- },- "mark": {- "attack": 194.7,- "defense": 192.0,- "lethality": 148.6,- "health": 149.5- }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminxx",- "heroes": {},- "troops": {- "lancer_t6": 2560- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 275.1,- "lethality": 215.3,- "health": 216.5- },- "lanc": {- "attack": 278.8,- "defense": 274.1,- "lethality": 199.7,- "health": 201.0- },- "mark": {- "attack": 284.6,- "defense": 281.6,- "lethality": 221.8,- "health": 216.9- }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "attacker": 7656,- "defender": 0- }- ]- },- {- "test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",- "description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",- "attacker": {- "name": "[BBQ]XxWIPxX",- "heroes": {},- "troops": {- "infantry_t6": 2700,- "lancer_t6": 2700,- "marksman_t6": 2700- },- "stats": {- "inf": {"attack": 221.4,"defense": 221.7,"lethality": 147.3,"health": 155.7@@ -206,5 +138,5 @@"defender": 0}]}-]\ No newline at end of file+]
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@@ -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;diff --git a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json--- a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json+++ b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json@@ -1,74 +1,4 @@[- {- "test_id": "wos444_bradley_s3_all_troops_damage_control_nc",- "description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",- "attacker": {- "name": "[BBQ]XxWIPxX",- "heroes": {},- "troops": {- "infantry_t6": 2520,- "lancer_t6": 2520,- "marksman_t6": 2520- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 221.7,- "lethality": 147.3,- "health": 155.7- },- "lanc": {- "attack": 194.7,- "defense": 193.0,- "lethality": 157.3,- "health": 148.4- },- "mark": {- "attack": 194.7,- "defense": 192.0,- "lethality": 148.6,- "health": 149.5- }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminxx",- "heroes": {},- "troops": {- "infantry_t6": 900,- "lancer_t6": 900,- "marksman_t6": 900- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 275.1,- "lethality": 215.3,- "health": 216.5- },- "lanc": {- "attack": 278.8,- "defense": 274.1,- "lethality": 199.7,- "health": 201.0- },- "mark": {- "attack": 284.6,- "defense": 281.6,- "lethality": 221.8,- "health": 216.9- }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "attacker": 5848,- "defender": 0- }- ]- },{"test_id": "wos444_bradley_s3_all_troops_damage_control_nc","description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",@@ -209,4 +139,4 @@}]}-]\ No newline at end of file+]
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;diff --git a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json--- a/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json+++ b/testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.json@@ -1,74 +1,4 @@[- {- "test_id": "wos444_bradley_s3_all_troops_damage_control_nc",- "description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",- "attacker": {- "name": "[BBQ]XxWIPxX",- "heroes": {},- "troops": {- "infantry_t6": 2520,- "lancer_t6": 2520,- "marksman_t6": 2520- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 221.7,- "lethality": 147.3,- "health": 155.7- },- "lanc": {- "attack": 194.7,- "defense": 193.0,- "lethality": 157.3,- "health": 148.4- },- "mark": {- "attack": 194.7,- "defense": 192.0,- "lethality": 148.6,- "health": 149.5- }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminxx",- "heroes": {},- "troops": {- "infantry_t6": 900,- "lancer_t6": 900,- "marksman_t6": 900- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 275.1,- "lethality": 215.3,- "health": 216.5- },- "lanc": {- "attack": 278.8,- "defense": 274.1,- "lethality": 199.7,- "health": 201.0- },- "mark": {- "attack": 284.6,- "defense": 281.6,- "lethality": 221.8,- "health": 216.9- }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "attacker": 5848,- "defender": 0- }- ]- },{"test_id": "wos444_bradley_s3_all_troops_damage_control_nc","description": "WOS-444 exact no-hero control for Bradley S3 all-troops damage-up bucket probe. WIP attacks 2520 each T6 mixed; minxxx defends 900 each T6 mixed. Same accounts, roles, troop counts, and report path as the paired hero fixture.",@@ -209,4 +139,4 @@}]}-]\ No newline at end of file+]diff --git a/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json b/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json--- a/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json+++ b/testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.json@@ -67,74 +67,6 @@}]},- {- "test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc",- "description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",- "attacker": {- "name": "[BBQ]XxWIPxX",- "heroes": {},- "troops": {- "infantry_t6": 2700,- "lancer_t6": 2700,- "marksman_t6": 2700- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 221.7,- "lethality": 147.3,- "health": 155.7- },- "lanc": {- "attack": 194.7,- "defense": 193.0,- "lethality": 157.3,- "health": 148.4- },- "mark": {- "attack": 194.7,- "defense": 192.0,- "lethality": 148.6,- "health": 149.5- }- },- "joiner_heroes": {}- },- "defender": {- "name": "[ARK]Piddlyminxx",- "heroes": {},- "troops": {- "lancer_t6": 2560- },- "stats": {- "inf": {- "attack": 0.0,- "defense": 275.1,- "lethality": 215.3,- "health": 216.5- },- "lanc": {- "attack": 278.8,- "defense": 274.1,- "lethality": 199.7,- "health": 201.0- },- "mark": {- "attack": 284.6,- "defense": 281.6,- "lethality": 221.8,- "health": 216.9- }- },- "joiner_heroes": {}- },- "game_report_result": [- {- "attacker": 7656,- "defender": 0- }- ]- },{"test_id": "wos444_gordon_s22_all_enemy_damage_dealt_control_nc","description": "WOS-444 exact no-hero control for Gordon S2/2 all-enemy damage-dealt-down bucket probe. WIP attacks 2700 each T6 mixed; minxxx defends 2560 T6 lancer. Same accounts, roles, troop counts, and report path as the paired hero fixture.",@@ -207,4 +139,4 @@}]}-]\ No newline at end of file+]