← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.19%
Avg Error B0.18%
Δ Avg Error-0.00%
Changed9
Improved6
Regressed3
Added0
Retired0
Testcase Delta
199 / 199
Code / Config Changes
Commits (dd63eaee → b246cb30)
b246cb30Major SImulateClient insanity refactorpiddlyminx08/07/2026, 17:44:12
Code Changes (Run A → Run B)
Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -244,8 +244,88 @@assert.equal(result.trace?.rounds[0]?.roundStartTroops.defender.infantry, 1);assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);});+test("simulateBattle skips later attacks against same-round exhausted targets only", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ stats: {+ infantry: { attack: 100000, lethality: 100000 },+ lancer: { attack: 100000, lethality: 100000 }+ },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1 },+ stats: { infantry: { attack: 100000, lethality: 100000 } },+ heroes: {}+ }+ },+ minimalConfig(),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");++ assert.deepEqual(+ normalAttacks.map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:normal", "r1:defender:infantry:0:normal"]+ );+ assert.equal(normalAttacks[0]?.kills, 1);+ assert.equal(normalAttacks[1]?.kills, 100);+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.id === "r1:attacker:lancer:1:normal"), false);+ assert.equal(result.remaining.attacker.infantry, 0);+ assert.equal(result.remaining.defender.infantry, 0);+});++test("extra skill attacks against same-round exhausted targets are skipped entirely", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { marksman_t1: 100 },+ stats: { marksman: { attack: 100000, lethality: 100000 } },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 1 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ FollowUpShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");++ assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, {});+ assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);+ assert.equal(result.remaining.defender.lancer, 0);+});+test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {const config = loadSimulatorConfig();const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -87,12 +87,17 @@}interface ExtraSkillJobsResult {jobs: DamageJob[];- usedEffects: ActiveEffect[];+ usedEffectGroups: ExtraSkillUsedEffectGroup[];appliedEffects?: AppliedExtraAttackEffect[];}+interface ExtraSkillUsedEffectGroup {+ sourceEffectId: string;+ effects: ActiveEffect[];+}+interface BattleRun {fighters: Record<SideId, ResolvedFighter>;runtime: Runtime;winner: SideId | "draw";@@ -330,10 +335,11 @@const cancelled: CancelledAttack[] = [];// Phase 1: fire all attack_declared triggers for every intended attack before// evaluating any controls or damage, per the battle-core spec.- const pendingNormalJobs: DamageJob[] = [];+ const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; control?: Control }> = [];const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];+ const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);declaredNormalJobs.push({ intent, job });@@ -342,8 +348,22 @@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!;+ pendingNormalJobs.push({ intent, job, control });+ } else {+ pendingNormalJobs.push({ intent, job });+ }+ }++ // Phase 2: calculate each normal job; any extra_skill_attack effect it+ // uses spawns extra DamageJobs that are calculated immediately after.+ // chargeUsedEffects runs after each job so subsequent normal jobs see the+ // correct uses count on any shared extra_skill_attack effects.+ for (const { intent, job, control } of pendingNormalJobs) {+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;++ if (control) {runtime.attackControlCounts[control.reason] += 1;if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);cancelled.push({intent,@@ -352,18 +372,11 @@appliedEffects: recorder.capturesAppliedEffects? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control)): NO_APPLIED_EFFECTS});- } else {- pendingNormalJobs.push(job);+ continue;}- }- // Phase 2: calculate each normal job; any extra_skill_attack effect it- // uses spawns extra DamageJobs that are calculated immediately after.- // chargeUsedEffects runs after each job so subsequent normal jobs see the- // correct uses count on any shared extra_skill_attack effects.- for (const job of pendingNormalJobs) {allJobs.push(job);const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,recordAppliedEffects: recorder.capturesAppliedEffects,@@ -372,22 +385,26 @@scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,capToDefenderTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects});+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}chargeUsedEffects(runtime);const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);- for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;- results.push({+ const processedExtraEffectIds = new Set<string>();+ const processedExtraJobIds = new Set<string>();+ const normalEntry: DamageJobResult = {job,result: normalResult,- extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)- });+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), undefined)+ };+ results.push(normalEntry);for (const extraJob of extraSkill.jobs) {+ if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;allJobs.push(extraJob);const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,recordAppliedEffects: recorder.capturesAppliedEffects,@@ -396,17 +413,27 @@scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,capToDefenderTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects});+ if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);+ processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");+ processedExtraJobIds.add(extraJob.id);+ if (extraJob.sourceEffectId) {+ runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;+ }results.push({ job: extraJob, result: extraResult });if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {score += extraResult.kills;}chargeUsedEffects(runtime);}+ normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));+ for (const usedEffectGroup of extraSkill.usedEffectGroups) {+ if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;+ for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ }}- if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);else commitRoundCounters(cancelled, results, runtime);for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);@@ -816,25 +843,25 @@roundStartTroops: DamageJob["roundStartTroops"],capturesTrace: boolean): ExtraSkillJobsResult {const jobs: DamageJob[] = [];- const usedEffects: ActiveEffect[] = [];+ const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round);for (const effectGroup of effectGroups) {const effect = effectGroup.selected;+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;const definitions = effect.triggerDamageJobs ?? [];const firstJobIndex = jobs.length;let definitionIndex = 0;for (const definition of definitions) {const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);if (multiplier <= 0) continue;- const sourceEffectId = effect.source.effectId ?? effect.intent.id;const sourceSkillReportKey = reportKeyForEffectSource(effect.source);for (const source of sources) {if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;for (const target of targets) {@@ -852,17 +879,16 @@sourceEffectId,sourceSkillReportKey,sourceMultiplier: multiplier});- runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;}}definitionIndex += 1;}if (jobs.length > firstJobIndex) {// The whole stacking group is charged whenever the selected effect spawned jobs,// regardless of duration constraints (extra attacks always deplete per firing).- usedEffects.push(...effectGroup.effects);+ usedEffectGroups.push({ sourceEffectId, effects: effectGroup.effects });if (capturesTrace) {(appliedEffects ??= []).push({kind: "extra_attack",...appliedEffectBase(effect),@@ -870,9 +896,9 @@});}}}- return { jobs, usedEffects, appliedEffects };+ return { jobs, usedEffectGroups, appliedEffects };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {const selected: ExtraAttackEffectGroup[] = [];@@ -957,31 +983,46 @@const pct = Number(raw ?? 0);return Number.isFinite(pct) ? pct / 100 : 0;}-// Cap each defender unit's total kills this round to the troops available at round start, applied-// in job order. Mutates result.kills (and the trace's finalKills when present). This is-// simulation-affecting and runs in every mode, before commit and recording.-function capRoundKills(results: DamageJobResult[], roundStartTroops: DamageJob["roundStartTroops"]): void {- for (const side of ["attacker", "defender"] as SideId[]) {- for (const unit of UNIT_TYPES) {- const matching = results.filter((entry) => entry.job.defenderSide === side && entry.job.defenderUnit === unit && entry.result.kills > 0);- if (matching.length === 0) continue;- const available = Math.max(0, roundStartTroops[side][unit] ?? 0);- const totalKills = matching.reduce((sum, entry) => sum + entry.result.kills, 0);- if (totalKills <= available) continue;- let appliedKills = 0;- let rawRemaining = available;- for (const entry of matching) {- const rawKills = entry.result.kills;- entry.result.kills = Math.min(rawKills, Math.max(0, available - appliedKills));- appliedKills += entry.result.kills;- rawRemaining = Math.max(0, rawRemaining - rawKills);- if (rawRemaining === 0) appliedKills = available;- if (entry.result.trace) entry.result.trace.finalKills = entry.result.kills;- }- }+function emptyRoundTargetDamage(): Record<SideId, Record<UnitType, number>> {+ return { attacker: emptyTroops(), defender: emptyTroops() };+}++function targetExhausted(+ job: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"],+ roundTargetDamage: Record<SideId, Record<UnitType, number>>+): boolean {+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);+ return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;+}++function capJobToRemainingTarget(+ result: DamageResult,+ job: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"],+ roundTargetDamage: Record<SideId, Record<UnitType, number>>+): void {+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);+ const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];+ const remaining = Math.max(0, available - alreadyDamaged);+ result.kills = Math.min(result.kills, remaining);+ roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;+ if (result.trace) result.trace.finalKills = result.kills;+}++function filterExtraAppliedEffects(+ events: AppliedExtraAttackEffect[] | undefined,+ processedExtraJobIds: Set<string>+): AppliedExtraAttackEffect[] | undefined {+ if (!events) return undefined;+ const filtered: AppliedExtraAttackEffect[] = [];+ for (const event of events) {+ const spawnedJobIds = event.spawnedJobIds.filter((jobId) => processedExtraJobIds.has(jobId));+ if (spawnedJobIds.length > 0) filtered.push({ ...event, spawnedJobIds });}+ return filtered.length > 0 ? filtered : undefined;}// Apply the round's effects to fighter state: remove killed troops and bump attack/received// counters. Every declared attack (each damage job and each cancelled attack) counts as oneIndex: simulator/src/testcases.test.ts===================================================================--- simulator/src/testcases.test.ts prev run+++ simulator/src/testcases.test.ts this run@@ -39,10 +39,10 @@assert.equal(summary?.testcase_id, "simple_001");assert.equal(summary?.deterministic, false);assert.equal(summary?.sampleCount, 5);assert.equal(typeof summary?.game?.mu_candidate, "number");- assert.equal(typeof summary?.baseline?.mu_candidate, "number");- assert.deepEqual(Object.keys(summary?.game ?? {}), Object.keys(summary?.baseline ?? {}));+ assert.equal(summary?.baseline, null);+ assert.equal(report.counts.comparedToBaseline, 0);assert.equal("result" in (summary as object), false);assert.ok(detail?.result);assert.equal(detail?.simulatorStats?.n, 5);assert.ok(detail?.visibility.attacker.troops.lancer);@@ -144,33 +144,34 @@assert.equal(report.details[0]?.errorDetails?.factor, -0.050000000000000044);assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");});-test("runTestcases keeps executed testcase and warns when baseline snapshot row is missing", () => {+test("runTestcases keeps executed testcase without warning when legacy baseline is unavailable", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);const summary = Object.values(report.testcases)[0];assert.equal(report.counts.executed, 1);assert.equal(summary?.game?.n_candidate, 1);assert.equal(summary?.baseline, null);- assert.equal(report.warnings[0]?.stage, "baseline_comparison");+ assert.equal(report.counts.comparedToBaseline, 0);+ assert.deepEqual(report.warnings, []);});-test("applyComparisonQValues keeps game and baseline correction families separate", () => {+test("applyComparisonQValues corrects game comparisons only", () => {const firstGame = comparisonMetric(0.01);const secondGame = comparisonMetric(0.02);- const onlyBaseline = comparisonMetric(0.04);+ const ignoredBaseline = comparisonMetric(0.04);const testcases: Record<string, TestcaseSummaryEntry> = {- "testcases/a.json#0": summaryEntry("a", 0, firstGame, onlyBaseline),+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),"testcases/b.json#0": summaryEntry("b", 0, secondGame, null)};applyComparisonQValues({ testcases });assert.equal(firstGame.q, 0.02);assert.equal(secondGame.q, 0.02);- assert.equal(onlyBaseline.q, 0.04);+ assert.equal(ignoredBaseline.q, null);});test("calibration lookup supports simulator symlink and source testcase path variants", () => {assert.deepEqual(testcaseFileLookupVariants("simulator/testcases/emulator_verified/simple_001_nc.json"), [@@ -184,9 +185,9 @@assert.deepEqual(readCalibrationCase(comparison, "simulator/testcases/emulator_verified/simple_001_nc.json", "simple_001"), sourceRow);}});-test("duplicate no-hero testcase ids align calibration rows by file and case index", () => {+test("duplicate no-hero testcase ids keep game rows aligned by case index", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);assert.equal(report.counts.testcasesFound, 2);@@ -197,29 +198,27 @@assert.equal(first?.testcaseId, "greg_mia_nohero_control_current");assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");assert.equal(first?.visibility.attacker.heroes.length, 0);assert.equal(second?.visibility.attacker.heroes.length, 0);- assert.equal(first?.calibration?.idx, 0);- assert.equal(second?.calibration?.idx, 1);- assert.equal(first?.calibration?.muGame, 3752);- assert.equal(second?.calibration?.muGame, 3652);- assert.equal(battleScoreDelta(first?.gameResult), first?.calibration?.muGame);- assert.equal(battleScoreDelta(second?.gameResult), second?.calibration?.muGame);+ assert.equal(first?.calibration, undefined);+ assert.equal(second?.calibration, undefined);+ assert.equal(battleScoreDelta(first?.gameResult), 3752);+ assert.equal(battleScoreDelta(second?.gameResult), 3652);});-test("no-hero simple testcase loads, runs, compares to calibration, and exposes aligned core fields", () => {+test("no-hero simple testcase loads, runs, compares to game result, and exposes aligned core fields", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);const entry = report.details[0];const summary = Object.values(report.testcases)[0];assert.equal(report.counts.testcasesFound, 1);assert.equal(summary?.testcase_id, "simple_001");assert.equal(summary?.game?.n_reference, 1);- assert.equal(summary?.baseline?.n_reference, 100);+ assert.equal(summary?.baseline, null);assert.equal(entry?.visibility.attacker.heroes.length, 0);assert.equal(entry?.visibility.defender.heroes.length, 0);- assert.equal(entry?.calibration?.muGame, -186);+ assert.equal(entry?.calibration, undefined);assert.equal(battleScoreDelta(entry?.gameResult), -186);assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);});@@ -254,9 +253,9 @@assert.equal(entry?.result?.winner, "attacker");assert.ok((entry?.result?.rounds ?? 0) > 100);});-test("runTestcases reports a parity summary from calibration JSON", () => {+test("runTestcases reports a game-focused parity summary without legacy baseline comparison", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);const row = Object.values(report.testcases)[0];const detail = report.details[0];@@ -264,23 +263,17 @@assert.ok(report.calibrationReportPath?.endsWith("baseline_result_2026-05-21T04-46-47Z.json"));assert.equal(row?.testcase_id, "simple_001");assert.equal(row?.idx, 0);assert.equal(row?.game?.mu_reference, -186);- assert.equal(row?.baseline?.mu_reference, -186);- assert.equal(row?.baseline?.bias_raw, 0);- assert.equal(row?.baseline?.sem, 0);- assert.equal(row?.baseline?.p, null);- assert.equal(row?.baseline?.q, null);+ assert.equal(row?.baseline, null);assert.equal(typeof detail?.simulatorScoreDelta, "number");assert.equal(typeof row?.game?.bias_raw, "number");assert.equal(row?.game?.n_candidate, 1);assert.equal(typeof row?.game?.mu_candidate, "number");- assert.equal(typeof row?.baseline?.bias_raw, "number");assert.equal(typeof row?.game?.bias_raw, "number");- assert.equal(typeof row?.baseline?.passes, "boolean");assert.equal(typeof row?.game?.passes, "boolean");assert.equal(report.counts.comparedToGame, 1);- assert.equal(report.counts.comparedToBaseline, 1);+ assert.equal(report.counts.comparedToBaseline, 0);});test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {const input = adaptTestcaseEntry({
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+]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+]
Run B dirty state patch
diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -245,6 +245,86 @@assert.equal(normalAttacks[1]?.trace?.roundStartTroops.defender.infantry, 1);});+test("simulateBattle skips later attacks against same-round exhausted targets only", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { infantry_t1: 100, lancer_t1: 100 },+ stats: {+ infantry: { attack: 100000, lethality: 100000 },+ lancer: { attack: 100000, lethality: 100000 }+ },+ heroes: {}+ },+ defender: {+ troops: { infantry_t1: 1 },+ stats: { infantry: { attack: 100000, lethality: 100000 } },+ heroes: {}+ }+ },+ minimalConfig(),+ { mode: "trace" }+ );++ const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");++ assert.deepEqual(+ normalAttacks.map((attack) => attack.jobId),+ ["r1:attacker:infantry:0:normal", "r1:defender:infantry:0:normal"]+ );+ assert.equal(normalAttacks[0]?.kills, 1);+ assert.equal(normalAttacks[1]?.kills, 100);+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.id === "r1:attacker:lancer:1:normal"), false);+ assert.equal(result.remaining.attacker.infantry, 0);+ assert.equal(result.remaining.defender.infantry, 0);+});++test("extra skill attacks against same-round exhausted targets are skipped entirely", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ attacker: {+ troops: { marksman_t1: 100 },+ stats: { marksman: { attack: 100000, lethality: 100000 } },+ heroes: { FollowUp: { skill_1: 1 } }+ },+ defender: {+ troops: { lancer_t1: 1 },+ heroes: {}+ }+ },+ minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ FollowUpShot: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ hitAgain: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],+ duration: { attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const normalAttack = result.attacks.find((attack) => attack.kind === "normal" && attack.attackerSide === "attacker");++ assert.equal(result.attacks.some((attack) => attack.kind === "skill"), false);+ assert.equal(result.trace?.rounds[0]?.jobs.some((job) => job.kind === "skill"), false);+ assert.deepEqual(result.extraSkillAttackJobsByEffect, {});+ assert.equal(normalAttack?.appliedEffects.some((effect) => effect.kind === "extra_attack"), false);+ assert.equal(result.remaining.defender.lancer, 0);+});+test("simulateBattle carries fractional casualties between rounds and ceils final survivors", () => {const config = loadSimulatorConfig();const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/simple_001_nc.json", import.meta.url));diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -88,10 +88,15 @@interface ExtraSkillJobsResult {jobs: DamageJob[];- usedEffects: ActiveEffect[];+ usedEffectGroups: ExtraSkillUsedEffectGroup[];appliedEffects?: AppliedExtraAttackEffect[];}+interface ExtraSkillUsedEffectGroup {+ sourceEffectId: string;+ effects: ActiveEffect[];+}+interface BattleRun {fighters: Record<SideId, ResolvedFighter>;runtime: Runtime;@@ -331,8 +336,9 @@// Phase 1: fire all attack_declared triggers for every intended attack before// evaluating any controls or damage, per the battle-core spec.- const pendingNormalJobs: DamageJob[] = [];+ const pendingNormalJobs: Array<{ intent: AttackIntent; job: DamageJob; control?: Control }> = [];const declaredNormalJobs: Array<{ intent: AttackIntent; job: DamageJob }> = [];+ const roundTargetDamage = emptyRoundTargetDamage();for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);@@ -343,6 +349,20 @@const controls = applicableControls(job, round, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;+ pendingNormalJobs.push({ intent, job, control });+ } else {+ pendingNormalJobs.push({ intent, job });+ }+ }++ // Phase 2: calculate each normal job; any extra_skill_attack effect it+ // uses spawns extra DamageJobs that are calculated immediately after.+ // chargeUsedEffects runs after each job so subsequent normal jobs see the+ // correct uses count on any shared extra_skill_attack effects.+ for (const { intent, job, control } of pendingNormalJobs) {+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;++ if (control) {runtime.attackControlCounts[control.reason] += 1;if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);cancelled.push({@@ -353,16 +373,9 @@? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control)): NO_APPLIED_EFFECTS});- } else {- pendingNormalJobs.push(job);+ continue;}- }- // Phase 2: calculate each normal job; any extra_skill_attack effect it- // uses spawns extra DamageJobs that are calculated immediately after.- // chargeUsedEffects runs after each job so subsequent normal jobs see the- // correct uses count on any shared extra_skill_attack effects.- for (const job of pendingNormalJobs) {allJobs.push(job);const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,@@ -373,20 +386,24 @@capToDefenderTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects});+ if (loopOptions.capRoundKills) capJobToRemainingTarget(normalResult, job, roundStartTroops, roundTargetDamage);if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {score += normalResult.kills;}chargeUsedEffects(runtime);const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesAppliedEffects);- for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;- results.push({+ const processedExtraEffectIds = new Set<string>();+ const processedExtraJobIds = new Set<string>();+ const normalEntry: DamageJobResult = {job,result: normalResult,- extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)- });+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), undefined)+ };+ results.push(normalEntry);for (const extraJob of extraSkill.jobs) {+ if (loopOptions.capRoundKills && targetExhausted(extraJob, roundStartTroops, roundTargetDamage)) continue;allJobs.push(extraJob);const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,@@ -397,15 +414,25 @@capToDefenderTroops: loopOptions.capJobKills,usedEffects: runtime.usedEffects});+ if (loopOptions.capRoundKills) capJobToRemainingTarget(extraResult, extraJob, roundStartTroops, roundTargetDamage);+ processedExtraEffectIds.add(extraJob.sourceEffectId ?? "");+ processedExtraJobIds.add(extraJob.id);+ if (extraJob.sourceEffectId) {+ runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[extraJob.sourceEffectId] ?? 0) + 1;+ }results.push({ job: extraJob, result: extraResult });if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {score += extraResult.kills;}chargeUsedEffects(runtime);}+ normalEntry.extraAppliedEffects = appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), filterExtraAppliedEffects(extraSkill.appliedEffects, processedExtraJobIds));+ for (const usedEffectGroup of extraSkill.usedEffectGroups) {+ if (!processedExtraEffectIds.has(usedEffectGroup.sourceEffectId)) continue;+ for (const usedEffect of usedEffectGroup.effects) usedEffect.uses += 1;+ }}- if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);else commitRoundCounters(cancelled, results, runtime);@@ -817,7 +844,7 @@capturesTrace: boolean): ExtraSkillJobsResult {const jobs: DamageJob[] = [];- const usedEffects: ActiveEffect[] = [];+ const usedEffectGroups: ExtraSkillUsedEffectGroup[] = [];let appliedEffects: AppliedExtraAttackEffect[] | undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),@@ -825,6 +852,7 @@);for (const effectGroup of effectGroups) {const effect = effectGroup.selected;+ const sourceEffectId = effect.source.effectId ?? effect.intent.id;const definitions = effect.triggerDamageJobs ?? [];const firstJobIndex = jobs.length;let definitionIndex = 0;@@ -833,7 +861,6 @@const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);const multiplier = multiplierForTriggerDamageJob(definition.multiplier, effect, round);if (multiplier <= 0) continue;- const sourceEffectId = effect.source.effectId ?? effect.intent.id;const sourceSkillReportKey = reportKeyForEffectSource(effect.source);for (const source of sources) {if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;@@ -853,7 +880,6 @@sourceSkillReportKey,sourceMultiplier: multiplier});- runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;}}definitionIndex += 1;@@ -861,7 +887,7 @@if (jobs.length > firstJobIndex) {// The whole stacking group is charged whenever the selected effect spawned jobs,// regardless of duration constraints (extra attacks always deplete per firing).- usedEffects.push(...effectGroup.effects);+ usedEffectGroups.push({ sourceEffectId, effects: effectGroup.effects });if (capturesTrace) {(appliedEffects ??= []).push({kind: "extra_attack",@@ -871,7 +897,7 @@}}}- return { jobs, usedEffects, appliedEffects };+ return { jobs, usedEffectGroups, appliedEffects };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {@@ -958,29 +984,44 @@return Number.isFinite(pct) ? pct / 100 : 0;}-// Cap each defender unit's total kills this round to the troops available at round start, applied-// in job order. Mutates result.kills (and the trace's finalKills when present). This is-// simulation-affecting and runs in every mode, before commit and recording.-function capRoundKills(results: DamageJobResult[], roundStartTroops: DamageJob["roundStartTroops"]): void {- for (const side of ["attacker", "defender"] as SideId[]) {- for (const unit of UNIT_TYPES) {- const matching = results.filter((entry) => entry.job.defenderSide === side && entry.job.defenderUnit === unit && entry.result.kills > 0);- if (matching.length === 0) continue;- const available = Math.max(0, roundStartTroops[side][unit] ?? 0);- const totalKills = matching.reduce((sum, entry) => sum + entry.result.kills, 0);- if (totalKills <= available) continue;- let appliedKills = 0;- let rawRemaining = available;- for (const entry of matching) {- const rawKills = entry.result.kills;- entry.result.kills = Math.min(rawKills, Math.max(0, available - appliedKills));- appliedKills += entry.result.kills;- rawRemaining = Math.max(0, rawRemaining - rawKills);- if (rawRemaining === 0) appliedKills = available;- if (entry.result.trace) entry.result.trace.finalKills = entry.result.kills;- }- }+function emptyRoundTargetDamage(): Record<SideId, Record<UnitType, number>> {+ return { attacker: emptyTroops(), defender: emptyTroops() };+}++function targetExhausted(+ job: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"],+ roundTargetDamage: Record<SideId, Record<UnitType, number>>+): boolean {+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);+ return available <= 0 || roundTargetDamage[job.defenderSide][job.defenderUnit] >= available;+}++function capJobToRemainingTarget(+ result: DamageResult,+ job: DamageJob,+ roundStartTroops: DamageJob["roundStartTroops"],+ roundTargetDamage: Record<SideId, Record<UnitType, number>>+): void {+ const available = Math.max(0, roundStartTroops[job.defenderSide][job.defenderUnit] ?? 0);+ const alreadyDamaged = roundTargetDamage[job.defenderSide][job.defenderUnit];+ const remaining = Math.max(0, available - alreadyDamaged);+ result.kills = Math.min(result.kills, remaining);+ roundTargetDamage[job.defenderSide][job.defenderUnit] += result.kills;+ if (result.trace) result.trace.finalKills = result.kills;+}++function filterExtraAppliedEffects(+ events: AppliedExtraAttackEffect[] | undefined,+ processedExtraJobIds: Set<string>+): AppliedExtraAttackEffect[] | undefined {+ if (!events) return undefined;+ const filtered: AppliedExtraAttackEffect[] = [];+ for (const event of events) {+ const spawnedJobIds = event.spawnedJobIds.filter((jobId) => processedExtraJobIds.has(jobId));+ if (spawnedJobIds.length > 0) filtered.push({ ...event, spawnedJobIds });}+ return filtered.length > 0 ? filtered : undefined;}// Apply the round's effects to fighter state: remove killed troops and bump attack/receiveddiff --git a/simulator/src/testcases.test.ts b/simulator/src/testcases.test.ts--- a/simulator/src/testcases.test.ts+++ b/simulator/src/testcases.test.ts@@ -40,8 +40,8 @@assert.equal(summary?.deterministic, false);assert.equal(summary?.sampleCount, 5);assert.equal(typeof summary?.game?.mu_candidate, "number");- assert.equal(typeof summary?.baseline?.mu_candidate, "number");- assert.deepEqual(Object.keys(summary?.game ?? {}), Object.keys(summary?.baseline ?? {}));+ assert.equal(summary?.baseline, null);+ assert.equal(report.counts.comparedToBaseline, 0);assert.equal("result" in (summary as object), false);assert.ok(detail?.result);assert.equal(detail?.simulatorStats?.n, 5);@@ -145,7 +145,7 @@assert.equal(Object.values(report.testcases)[0]?.testcase_id, "next_case_runs");});-test("runTestcases keeps executed testcase and warns when baseline snapshot row is missing", () => {+test("runTestcases keeps executed testcase without warning when legacy baseline is unavailable", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1, calibrationReportPath: "/tmp/does-not-exist.json" }, config);const summary = Object.values(report.testcases)[0];@@ -153,15 +153,16 @@assert.equal(report.counts.executed, 1);assert.equal(summary?.game?.n_candidate, 1);assert.equal(summary?.baseline, null);- assert.equal(report.warnings[0]?.stage, "baseline_comparison");+ assert.equal(report.counts.comparedToBaseline, 0);+ assert.deepEqual(report.warnings, []);});-test("applyComparisonQValues keeps game and baseline correction families separate", () => {+test("applyComparisonQValues corrects game comparisons only", () => {const firstGame = comparisonMetric(0.01);const secondGame = comparisonMetric(0.02);- const onlyBaseline = comparisonMetric(0.04);+ const ignoredBaseline = comparisonMetric(0.04);const testcases: Record<string, TestcaseSummaryEntry> = {- "testcases/a.json#0": summaryEntry("a", 0, firstGame, onlyBaseline),+ "testcases/a.json#0": summaryEntry("a", 0, firstGame, ignoredBaseline),"testcases/b.json#0": summaryEntry("b", 0, secondGame, null)};@@ -169,7 +170,7 @@assert.equal(firstGame.q, 0.02);assert.equal(secondGame.q, 0.02);- assert.equal(onlyBaseline.q, 0.04);+ assert.equal(ignoredBaseline.q, null);});test("calibration lookup supports simulator symlink and source testcase path variants", () => {@@ -185,7 +186,7 @@}});-test("duplicate no-hero testcase ids align calibration rows by file and case index", () => {+test("duplicate no-hero testcase ids keep game rows aligned by case index", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "greg_mia_nohero_control_current", repeat: 1 }, config);@@ -198,15 +199,13 @@assert.equal(second?.testcaseId, "greg_mia_nohero_control_current");assert.equal(first?.visibility.attacker.heroes.length, 0);assert.equal(second?.visibility.attacker.heroes.length, 0);- assert.equal(first?.calibration?.idx, 0);- assert.equal(second?.calibration?.idx, 1);- assert.equal(first?.calibration?.muGame, 3752);- assert.equal(second?.calibration?.muGame, 3652);- assert.equal(battleScoreDelta(first?.gameResult), first?.calibration?.muGame);- assert.equal(battleScoreDelta(second?.gameResult), second?.calibration?.muGame);+ assert.equal(first?.calibration, undefined);+ assert.equal(second?.calibration, undefined);+ assert.equal(battleScoreDelta(first?.gameResult), 3752);+ assert.equal(battleScoreDelta(second?.gameResult), 3652);});-test("no-hero simple testcase loads, runs, compares to calibration, and exposes aligned core fields", () => {+test("no-hero simple testcase loads, runs, compares to game result, and exposes aligned core fields", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);const entry = report.details[0];@@ -215,10 +214,10 @@assert.equal(report.counts.testcasesFound, 1);assert.equal(summary?.testcase_id, "simple_001");assert.equal(summary?.game?.n_reference, 1);- assert.equal(summary?.baseline?.n_reference, 100);+ assert.equal(summary?.baseline, null);assert.equal(entry?.visibility.attacker.heroes.length, 0);assert.equal(entry?.visibility.defender.heroes.length, 0);- assert.equal(entry?.calibration?.muGame, -186);+ assert.equal(entry?.calibration, undefined);assert.equal(battleScoreDelta(entry?.gameResult), -186);assert.equal(battleScoreDelta(entry?.result), entry ? entry.result!.remaining.attacker.infantry + entry.result!.remaining.attacker.lancer + entry.result!.remaining.attacker.marksman - (entry.result!.remaining.defender.infantry + entry.result!.remaining.defender.lancer + entry.result!.remaining.defender.marksman) : undefined);});@@ -255,7 +254,7 @@assert.ok((entry?.result?.rounds ?? 0) > 100);});-test("runTestcases reports a parity summary from calibration JSON", () => {+test("runTestcases reports a game-focused parity summary without legacy baseline comparison", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "simple_001", repeat: 1 }, config);const row = Object.values(report.testcases)[0];@@ -265,21 +264,15 @@assert.equal(row?.testcase_id, "simple_001");assert.equal(row?.idx, 0);assert.equal(row?.game?.mu_reference, -186);- assert.equal(row?.baseline?.mu_reference, -186);- assert.equal(row?.baseline?.bias_raw, 0);- assert.equal(row?.baseline?.sem, 0);- assert.equal(row?.baseline?.p, null);- assert.equal(row?.baseline?.q, null);+ assert.equal(row?.baseline, null);assert.equal(typeof detail?.simulatorScoreDelta, "number");assert.equal(typeof row?.game?.bias_raw, "number");assert.equal(row?.game?.n_candidate, 1);assert.equal(typeof row?.game?.mu_candidate, "number");- assert.equal(typeof row?.baseline?.bias_raw, "number");assert.equal(typeof row?.game?.bias_raw, "number");- assert.equal(typeof row?.baseline?.passes, "boolean");assert.equal(typeof row?.game?.passes, "boolean");assert.equal(report.counts.comparedToGame, 1);- assert.equal(report.counts.comparedToBaseline, 1);+ assert.equal(report.counts.comparedToBaseline, 0);});test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {