← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.20%
Avg Error B0.20%
Δ Avg Error0.00%
Improved0
Regressed0
Added0
Retired0
Testcase Delta
178 / 178
Code / Config Changes
Code Changes (Run A → Run B)
Index: simulator/src/testcases.test.ts===================================================================--- simulator/src/testcases.test.ts prev run+++ simulator/src/testcases.test.ts this run@@ -284,8 +284,27 @@assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });});+test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {+ const input = adaptTestcaseEntry(+ {+ test_id: "mechanics_case",+ engagement_type: "rally",+ mechanics: { weather: "clear" },+ attacker: { troops: { infantry_t1: 1 } },+ defender: { troops: { infantry_t1: 1 } }+ },+ { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }+ );++ assert.deepEqual(input.mechanics, {+ weather: "clear",+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,+ engagement_type: "rally"+ });+});+test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {const metrics = compareOutcomeDistribution({candidate: { n: 1, mu: -186, sigma: 0 },reference: { n: 1, mu: -186, sigma: 0 },Index: simulator/src/testcases.ts===================================================================--- simulator/src/testcases.ts prev run+++ simulator/src/testcases.ts this run@@ -26,8 +26,9 @@repeat?: number;seed?: string | number;trace?: boolean;workers?: number;+ mechanics?: Record<string, unknown>;}export interface TestcaseCaseReport {file: string;@@ -206,9 +207,9 @@const diagnostics: string[] = [];const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };try {- preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace }, diagnostics);+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);preparedCase.key = snapshotKey(reportFile, index);} catch (error) {detail.error = errorMessage(error);diagnostics.push(detail.error);@@ -612,9 +613,13 @@const { details: _details, ...summary } = report;return summary;}-export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {+export function adaptTestcaseEntry(+ entry: unknown,+ options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},+ diagnostics: string[] = []+): BattleInput {const object = entry as {attacker?: FighterInput;defender?: FighterInput;test_id?: string;@@ -625,9 +630,9 @@max_rounds?: unknown;};if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));- const mechanics = testcaseMechanics(object);+ const mechanics = testcaseMechanics(object, options.mechanics);const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);return {attacker: object.attacker,defender: object.defender,@@ -701,10 +706,14 @@if (!fighter.stats) diagnostics.push(`${side} has no stats block`);return diagnostics;}-function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {+function testcaseMechanics(+ entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },+ optionMechanics?: Record<string, unknown>+): Record<string, unknown> | undefined {const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};+ if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;return Object.keys(mechanics).length > 0 ? mechanics : undefined;}
Show raw per-run patches
Run A dirty state patch
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -38,6 +38,7 @@effect: ActiveEffect;bucket: AtomicBucket;valuePct: number;+ carriedInactive?: boolean;}interface MaxBucketCandidateGroup {@@ -123,13 +124,17 @@const consumedEffectIds = new Set<string>();const candidates: BucketCandidate[] = [];const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;+ const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- if (!isEffectActive(candidate.effect, job.round)) continue;+ const active = isEffectActive(candidate.effect, job.round);+ const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;+ if (!active && !carriedInactive) continue;handledCandidateEffectIds?.add(candidate.effect.id);candidates.push({effect: candidate.effect,bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round)+ valuePct: currentEffectValuePct(candidate.effect, job.round),+ carriedInactive});}@@ -267,7 +272,7 @@appliedEffects.push(appliedEffect);}for (const candidate of candidates) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) consumedEffectIds.add(candidate.effect.id);if (detail === "full" && candidate !== selected) {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });}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@@ -1323,6 +1323,87 @@assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });});+test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ attackDurationCarryConfig()+ );++ const normalBoosts = result.attacks+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);+ const skillBoosts = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);+ assert.deepEqual(normalBoosts, [100]);+ assert.deepEqual(skillBoosts, [0]);+});++test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })+ );++ const normalBoosts = result.attacks+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);+ const skillBoostsByTarget = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)+ .sort();+ assert.deepEqual(normalBoosts, [100]);+ assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);+});++test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })+ );++ const skillBoosts = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)+ .sort((left, right) => left - right);+ assert.deepEqual(skillBoosts, [100, 100, 100]);+});+test("attack-triggered source and target selectors resolve to concrete active scopes", () => {const result = simulateBattle({@@ -1801,3 +1882,40 @@diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }};}++function attackDurationCarryConfig(+ options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}+): SimulatorConfig {+ const buffAppliesVs = options.buffAppliesVs ?? "any";+ const extraTargets = options.extraTargets ?? "use.target";+ return minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ OneUseNormalBoost: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "marksman", applies_vs: buffAppliesVs },+ duration: { type: "attack", value: 1 }+ }+ }+ },+ TriggeredExtraDamage: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ extra: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ });+}diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -53,6 +53,7 @@extraSkillAttackJobsByEffect: Record<string, number>;attackControlCounts: { dodge: number; no_attack: number };consumedEffectUseKeys: Set<string>;+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;counters: {attacks: Record<SideId, Record<UnitType, number>>;received: Record<SideId, Record<UnitType, number>>;@@ -112,7 +113,7 @@const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);const defender = resolveFighter(input.defender, "defender", config, input.mechanics);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));+ const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"), input.mechanics);const detail = options.detail ?? "full";const traceEnabled = detail === "full" && input.trace;const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;@@ -289,7 +290,7 @@return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {+function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };const skills = buildRuntimeSkills(fighters);for (const fighter of fighters) {@@ -321,6 +322,7 @@extraSkillAttackJobsByEffect: {},attackControlCounts: { dodge: 0, no_attack: 0 },consumedEffectUseKeys: new Set(),+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,counters: {attacks: { attacker: emptyTroops(), defender: emptyTroops() },received: { attacker: emptyTroops(), defender: emptyTroops() }@@ -513,6 +515,9 @@roundStartTroops: DamageJob["roundStartTroops"]): DamageJob[] {const jobs: DamageJob[] = [];+ const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage+ ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)+ : undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round@@ -548,6 +553,7 @@sourceEffectId,sourceSkillReportKey,sourceMultiplier: multiplier,+ carriedAttackDurationEffectIds,consumedEffectIds,consumedEffectUseKey,consumedEffectUseId: effect.id,@@ -745,6 +751,16 @@.map((effect) => effect.id);}+function attackDurationBucketEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {+ return effects+ .filter((effect) => {+ if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;+ const classification = classifyEffectForJob(effect, job);+ return classification?.kind === "bucket";+ })+ .map((effect) => effect.id);+}+function consumeEffects(runtime: Runtime,consumedEffectIds: string[],diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -277,6 +277,7 @@sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;+ carriedAttackDurationEffectIds?: string[];consumedEffectIds?: string[];consumedEffectUseKey?: string;consumedEffectUseId?: string;
Run B dirty state patch
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -38,6 +38,7 @@effect: ActiveEffect;bucket: AtomicBucket;valuePct: number;+ carriedInactive?: boolean;}interface MaxBucketCandidateGroup {@@ -123,13 +124,17 @@const consumedEffectIds = new Set<string>();const candidates: BucketCandidate[] = [];const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;+ const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- if (!isEffectActive(candidate.effect, job.round)) continue;+ const active = isEffectActive(candidate.effect, job.round);+ const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;+ if (!active && !carriedInactive) continue;handledCandidateEffectIds?.add(candidate.effect.id);candidates.push({effect: candidate.effect,bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round)+ valuePct: currentEffectValuePct(candidate.effect, job.round),+ carriedInactive});}@@ -267,7 +272,7 @@appliedEffects.push(appliedEffect);}for (const candidate of candidates) {- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);+ if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) consumedEffectIds.add(candidate.effect.id);if (detail === "full" && candidate !== selected) {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });}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@@ -1323,6 +1323,87 @@assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });});+test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100 },+ heroes: {}+ }+ },+ attackDurationCarryConfig()+ );++ const normalBoosts = result.attacks+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);+ const skillBoosts = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);+ assert.deepEqual(normalBoosts, [100]);+ assert.deepEqual(skillBoosts, [0]);+});++test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })+ );++ const normalBoosts = result.attacks+ .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);+ const skillBoostsByTarget = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)+ .sort();+ assert.deepEqual(normalBoosts, [100]);+ assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);+});++test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {+ const result = simulateBattle(+ {+ maxRounds: 1,+ trace: true,+ mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },+ attacker: {+ troops: { marksman_t1: 100 },+ heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },+ heroes: {}+ }+ },+ attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })+ );++ const skillBoosts = result.attacks+ .filter((attack) => attack.kind === "skill")+ .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)+ .sort((left, right) => left - right);+ assert.deepEqual(skillBoosts, [100, 100, 100]);+});+test("attack-triggered source and target selectors resolve to concrete active scopes", () => {const result = simulateBattle({@@ -1801,3 +1882,40 @@diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }};}++function attackDurationCarryConfig(+ options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}+): SimulatorConfig {+ const buffAppliesVs = options.buffAppliesVs ?? "any";+ const extraTargets = options.extraTargets ?? "use.target";+ return minimalConfig({+ FollowUp: {+ name: "FollowUp",+ skills: {+ OneUseNormalBoost: {+ trigger: { type: "battle_start" },+ effects: {+ boost: {+ type: "active.hero.attack.up",+ value: 100,+ units: { applies_to: "marksman", applies_vs: buffAppliesVs },+ duration: { type: "attack", value: 1 }+ }+ }+ },+ TriggeredExtraDamage: {+ trigger: { type: "attack", probability: 100, source: "marksman" },+ effects: {+ extra: {+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "any" },+ trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],+ duration: { type: "attack", value: 1 }+ }+ }+ }+ }+ }+ });+}diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -53,6 +53,7 @@extraSkillAttackJobsByEffect: Record<string, number>;attackControlCounts: { dodge: number; no_attack: number };consumedEffectUseKeys: Set<string>;+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;counters: {attacks: Record<SideId, Record<UnitType, number>>;received: Record<SideId, Record<UnitType, number>>;@@ -112,7 +113,7 @@const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);const defender = resolveFighter(input.defender, "defender", config, input.mechanics);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));+ const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"), input.mechanics);const detail = options.detail ?? "full";const traceEnabled = detail === "full" && input.trace;const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;@@ -289,7 +290,7 @@return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {+function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };const skills = buildRuntimeSkills(fighters);for (const fighter of fighters) {@@ -321,6 +322,7 @@extraSkillAttackJobsByEffect: {},attackControlCounts: { dodge: 0, no_attack: 0 },consumedEffectUseKeys: new Set(),+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,counters: {attacks: { attacker: emptyTroops(), defender: emptyTroops() },received: { attacker: emptyTroops(), defender: emptyTroops() }@@ -513,6 +515,9 @@roundStartTroops: DamageJob["roundStartTroops"]): DamageJob[] {const jobs: DamageJob[] = [];+ const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage+ ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)+ : undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round@@ -548,6 +553,7 @@sourceEffectId,sourceSkillReportKey,sourceMultiplier: multiplier,+ carriedAttackDurationEffectIds,consumedEffectIds,consumedEffectUseKey,consumedEffectUseId: effect.id,@@ -745,6 +751,16 @@.map((effect) => effect.id);}+function attackDurationBucketEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {+ return effects+ .filter((effect) => {+ if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;+ const classification = classifyEffectForJob(effect, job);+ return classification?.kind === "bucket";+ })+ .map((effect) => effect.id);+}+function consumeEffects(runtime: Runtime,consumedEffectIds: string[],diff --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@@ -285,6 +285,25 @@assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });});+test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {+ const input = adaptTestcaseEntry(+ {+ test_id: "mechanics_case",+ engagement_type: "rally",+ mechanics: { weather: "clear" },+ attacker: { troops: { infantry_t1: 1 } },+ defender: { troops: { infantry_t1: 1 } }+ },+ { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }+ );++ assert.deepEqual(input.mechanics, {+ weather: "clear",+ carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,+ engagement_type: "rally"+ });+});+test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {const metrics = compareOutcomeDistribution({candidate: { n: 1, mu: -186, sigma: 0 },diff --git a/simulator/src/testcases.ts b/simulator/src/testcases.ts--- a/simulator/src/testcases.ts+++ b/simulator/src/testcases.ts@@ -27,6 +27,7 @@seed?: string | number;trace?: boolean;workers?: number;+ mechanics?: Record<string, unknown>;}export interface TestcaseCaseReport {@@ -207,7 +208,7 @@const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };try {- preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace }, diagnostics);+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);preparedCase.key = snapshotKey(reportFile, index);} catch (error) {detail.error = errorMessage(error);@@ -613,7 +614,11 @@return summary;}-export function adaptTestcaseEntry(entry: unknown, options: { seed?: string | number; trace?: boolean } = {}, diagnostics: string[] = []): BattleInput {+export function adaptTestcaseEntry(+ entry: unknown,+ options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},+ diagnostics: string[] = []+): BattleInput {const object = entry as {attacker?: FighterInput;defender?: FighterInput;@@ -626,7 +631,7 @@};if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));- const mechanics = testcaseMechanics(object);+ const mechanics = testcaseMechanics(object, options.mechanics);const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);return {attacker: object.attacker,@@ -702,8 +707,12 @@return diagnostics;}-function testcaseMechanics(entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown }): Record<string, unknown> | undefined {+function testcaseMechanics(+ entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },+ optionMechanics?: Record<string, unknown>+): Record<string, unknown> | undefined {const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};+ if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;return Object.keys(mechanics).length > 0 ? mechanics : undefined;diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -277,6 +277,7 @@sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;+ carriedAttackDurationEffectIds?: string[];consumedEffectIds?: string[];consumedEffectUseKey?: string;consumedEffectUseId?: string;