← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.20%
Avg Error B0.20%
Δ Avg Error+0.00%
Improved0
Regressed0
Added1
Retired0
Testcase Delta
178 / 178
Code / Config Changes
Commits (5c70816e → 40ddd336)
40ddd336Add Ambusher no-marksmen emulator testcasepiddlyminx11/06/2026, 02:24:14
Code Changes (Run A → Run B)
Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -37,8 +37,9 @@interface BucketCandidate {effect: ActiveEffect;bucket: AtomicBucket;valuePct: number;+ carriedInactive?: boolean;}interface MaxBucketCandidateGroup {selected: BucketCandidate;@@ -122,15 +123,19 @@const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];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});}if (traceEnabled) {@@ -266,9 +271,9 @@if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;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" });}}Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -1322,8 +1322,89 @@assert.deepEqual(skillBoosts, [100, 100, 0, 0]);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({maxRounds: 1,@@ -1800,4 +1881,41 @@troopSkills: { name: "troop skills", skills: {} },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 }+ }+ }+ }+ }+ }+ });+}Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -52,8 +52,9 @@effectActivationCounts: Record<SideId, number>;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>>;};@@ -111,9 +112,9 @@function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {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;const attacks: AttackOutcome[] = [];@@ -288,9 +289,9 @@const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);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) {for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {@@ -320,8 +321,9 @@effectActivationCounts: { attacker: 0, defender: 0 },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() }}@@ -512,8 +514,11 @@runtime: Runtime,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);@@ -547,8 +552,9 @@defenderUnit: target.unit,sourceEffectId,sourceSkillReportKey,sourceMultiplier: multiplier,+ carriedAttackDurationEffectIds,consumedEffectIds,consumedEffectUseKey,consumedEffectUseId: effect.id,consumedEffectUseIds: consumedEffectIds@@ -744,8 +750,18 @@}).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[],consumedEffectUseKey?: string,Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -276,8 +276,9 @@defenderUnit: UnitType;sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;+ carriedAttackDurationEffectIds?: string[];consumedEffectIds?: string[];consumedEffectUseKey?: string;consumedEffectUseId?: string;consumedEffectUseIds?: string[];Index: testcases/emulator_verified/ambusher_no_marksmen.json===================================================================--- testcases/emulator_verified/ambusher_no_marksmen.json prev run+++ testcases/emulator_verified/ambusher_no_marksmen.json this run@@ -0,0 +1,61 @@+[+ {+ "test_id": "ambusher_no_marksmen",+ "description": "Field battle (direct attack, not a rally) , battle 2026-06-07 21:33:44. Pins Ambusher target-order fallback: attacker t10fc8 lancers have Ambusher (tier 7+) and the game report shows it triggering, but the defender fields ZERO marksmen. Game ground truth (defender per-type table): lancers 0 losses / 0 injured / 0 lightly injured, survivors 81,191 of 81,191; ALL defender damage fell on infantry (-3,085 injured, 7,198 lightly injured, 24,495 of 34,778 survivors). Proves ambush order falls back to infantry before lancers when marksmen are absent, i.e. order marksman -> infantry -> lancer, matching config troop_skills.json Ambusher. All hero skills level 5 (skills 1-3 only, widget skill 4 not relevant, no rally/garrison engagement). Survivor totals from Battle Overview Survivors row; per-skill triggers from Battle Details: defender Edith 1/1/1(+31), Renee 10/1/1 (2,373 skill kills), Wayne 10/-/8 (7,266 + 6,333 skill kills); attacker Edith 1/1/1(+25), Renee 10/1/1 (225 skill kills), Lynn 8/1/2 (146 skill kills).",+ "attacker": {+ "name": "[PWR]Longlive _Anata",+ "heroes": {+ "Edith": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },+ "Renee": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },+ "Lynn": { "skill_1": 5, "skill_2": 5, "skill_3": 5 }+ },+ "troops": {+ "infantry_t11_fc8": 94385,+ "lancer_t10_fc8": 37754,+ "marksman_t10_fc8": 56632+ },+ "stats": {+ "inf": { "attack": 1214.6, "defense": 1298.7, "lethality": 640.4, "health": 490.2 },+ "lanc": { "attack": 1220.3, "defense": 1320.2, "lethality": 659.6, "health": 517.7 },+ "mark": { "attack": 1134.9, "defense": 1177.3, "lethality": 739.1, "health": 565.4 }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[ARK]Piddlyminx",+ "heroes": {+ "Edith": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },+ "Renee": { "skill_1": 5, "skill_2": 5, "skill_3": 5 },+ "Wayne": { "skill_1": 5, "skill_2": 5, "skill_3": 5 }+ },+ "troops": {+ "infantry_t11_fc8": 34778,+ "lancer_t11_fc8": 81191+ },+ "stats": {+ "inf": { "attack": 1890.1, "defense": 1873.8, "lethality": 1595.0, "health": 1824.8 },+ "lanc": { "attack": 1710.0, "defense": 1669.8, "lethality": 1183.3, "health": 1081.2 },+ "mark": { "attack": 1810.0, "defense": 1766.9, "lethality": 1926.7, "health": 1667.3 }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "timestamp": "2026-06-07 21:33",+ "attacker": 0,+ "defender": 105686+ }+ ],+ "game_report_detail": {+ "source": "read-only manual capture of open report, Piddlyminx instance, mail_id 2734682983265503",+ "battle_overview": {+ "attacker": { "troops": 188771, "losses": 0, "injured": 56633, "lightly_injured": 132138, "survivors": 0 },+ "defender": { "troops": 115969, "losses": 0, "injured": 3085, "lightly_injured": 7198, "survivors": 105686 }+ },+ "defender_by_type": {+ "infantry": { "kills": 7132, "losses": 0, "injured": 3085, "lightly_injured": 7198, "survivors": 24495 },+ "lancer": { "kills": 49501, "losses": 0, "injured": 0, "lightly_injured": 0, "survivors": 81191 }+ }+ }+ }+]
Show raw per-run patches
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/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;