← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.20%
Avg Error B0.21%
Δ Avg Error+0.00%
Improved0
Regressed1
Added0
Retired0
Testcase Delta
178 / 178
Code / Config Changes
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@@ -822,8 +822,38 @@assert.equal(result.remaining.attacker.marksman, 1349);assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });});+test("committed losses clamp exhausted floating point residue before next-round target selection", () => {+ const config = loadSimulatorConfig();+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/renee_solo_nc.json", import.meta.url));+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;+ const input = testcases.find((testcase) => testcase.test_id === "renee_solo_nc");+ if (!input) throw new Error("missing renee_solo_nc fixture");++ const adjusted = structuredClone(input);+ for (const [fighter, adjustment] of [+ [adjusted.attacker, 0.05],+ [adjusted.defender, -0.05]+ ] as const) {+ for (const stats of Object.values(fighter.stats ?? {})) {+ for (const key of ["attack", "defense", "lethality", "health"] as const) {+ if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));+ }+ }+ }+ adjusted.trace = true;++ const result = simulateBattle(adjusted, config);+ const roundSix = result.trace?.rounds.find((round) => round.round === 6);+ const defenderTargets = roundSix?.intents+ .filter((intent) => intent.attackerSide === "defender")+ .map((intent) => intent.defenderUnit);++ assert.equal(roundSix?.roundStartTroops.attacker.infantry, 0);+ assert.deepEqual(defenderTargets, ["lancer", "lancer", "lancer"]);+});+test("extra skill trigger damage jobs reject missing runtime selectors", () => {assert.throws(() =>simulateBattle(Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -688,12 +688,16 @@if (matching.length === 0) continue;const available = Math.max(0, roundStartTroops[side][unit] ?? 0);const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);if (totalKills <= available) continue;- let remaining = available;+ let appliedKills = 0;+ let rawRemaining = available;for (const outcome of matching) {- outcome.kills = Math.min(outcome.kills, remaining);- remaining -= outcome.kills;+ const rawKills = outcome.kills;+ outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));+ appliedKills += outcome.kills;+ rawRemaining = Math.max(0, rawRemaining - rawKills);+ if (rawRemaining === 0) appliedKills = available;if (outcome.trace) outcome.trace.finalKills = outcome.kills;}}}
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/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;
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@@ -823,6 +823,36 @@assert.deepEqual(result.remaining.defender, { infantry: 0, lancer: 0, marksman: 0 });});+test("committed losses clamp exhausted floating point residue before next-round target selection", () => {+ const config = loadSimulatorConfig();+ const fixturePath = fileURLToPath(new URL("../testcases/emulator_verified/renee_solo_nc.json", import.meta.url));+ const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;+ const input = testcases.find((testcase) => testcase.test_id === "renee_solo_nc");+ if (!input) throw new Error("missing renee_solo_nc fixture");++ const adjusted = structuredClone(input);+ for (const [fighter, adjustment] of [+ [adjusted.attacker, 0.05],+ [adjusted.defender, -0.05]+ ] as const) {+ for (const stats of Object.values(fighter.stats ?? {})) {+ for (const key of ["attack", "defense", "lethality", "health"] as const) {+ if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));+ }+ }+ }+ adjusted.trace = true;++ const result = simulateBattle(adjusted, config);+ const roundSix = result.trace?.rounds.find((round) => round.round === 6);+ const defenderTargets = roundSix?.intents+ .filter((intent) => intent.attackerSide === "defender")+ .map((intent) => intent.defenderUnit);++ assert.equal(roundSix?.roundStartTroops.attacker.infantry, 0);+ assert.deepEqual(defenderTargets, ["lancer", "lancer", "lancer"]);+});+test("extra skill trigger damage jobs reject missing runtime selectors", () => {assert.throws(() =>@@ -1323,6 +1353,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 +1912,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,@@ -683,10 +689,14 @@const available = Math.max(0, roundStartTroops[side][unit] ?? 0);const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);if (totalKills <= available) continue;- let remaining = available;+ let appliedKills = 0;+ let rawRemaining = available;for (const outcome of matching) {- outcome.kills = Math.min(outcome.kills, remaining);- remaining -= outcome.kills;+ const rawKills = outcome.kills;+ outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));+ appliedKills += outcome.kills;+ rawRemaining = Math.max(0, rawRemaining - rawKills);+ if (rawRemaining === 0) appliedKills = available;if (outcome.trace) outcome.trace.finalKills = outcome.kills;}}@@ -745,6 +755,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;