← Back to Runs
Run Detail
18095737-0e11-433d-9434-e1e56f42d473
Started05/07/2026, 07:20:41
Git SHAdd63eaee
Statedirty
Avg Error %0.19%
BH Sig Count3
Total Cases201
Passing183
Run Report—
Code Changes Since Previous Run
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 Dirty State Patch (vs Clean Baseline)
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;
Accuracy Results
201 / 201