← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.14%
Avg Error B0.13%
Δ Avg Error-0.01%
Changed6
Improved6
Regressed0
Added0
Retired0
Testcase Delta
414 / 414
Code / Config Changes
Code Changes (Run A → Run B)
Index: simulator/config/hero_definitions/README.md===================================================================--- simulator/config/hero_definitions/README.md prev run+++ simulator/config/hero_definitions/README.md this run@@ -458,14 +458,16 @@Target-specific modifiers are selected against each generated job's actual recipient. They do not inherit the initiating normal attack's target or its matchup bonuses.The extra-attack effect is charged once for the parent normal attack if at least one of its jobs runs. If its `value` is non-positive, every job lacks living source/target troops, or every job is skipped because its target is already exhausted, the effect is not charged and can remain available.-Immediate and delayed hits share the same generation and delivery routines. Generation uses reusable scratch and the prepared modifier-group index to calculate non-shield damage. Delivery selects applicable shields from a small live list, consumes protection, and applies the current target-troop cap. Shield activation and expiry use the ordinary effect scheduler; shields retain group identity for stacking and dependencies but are excluded from the modifier job-shape lists.+Immediate and delayed hits share bucket evaluation, shield settlement, and target caps. Immediate hits calculate and consume their modifiers together. Delayed hits instead separate captured contributions from attack-limited target modifiers.-When an `extra_skill_attack` is materialized as a carrier's `trigger_effects` child, its generated damage is retained on that child ActiveEffect. Fast and standard modes retain scalar calculation state and the recording context; only trace mode copies the full bucket factors. Shields are not selected, advanced, or consumed during generation. The child can deliver its damage only when a later normal attack matches its resolved source and target scope, after that normal hit has resolved. Delivery uses the generation-time health conversion factor for shield-budget conversion without recalculating the damage. If the matching attack never occurs during the child's duration, the pending damage expires without consuming shields.+When an `extra_skill_attack` is materialized as a carrier's `trigger_effects` child, its generated damage is retained on that child ActiveEffect. All modes retain owned dynamic factors and captured static terms so delivery can combine modifiers without recalculating the source's troop strength. Attack-limited target modifiers and shields are not selected, advanced, or consumed during capture. When a later normal attack matches the child's source and target scope, pending damage lands before that attack is declared and before it creates new effects. Delivery applies the currently eligible attack-limited target modifiers to the captured buckets, consumes their uses, and settles live shields and the current target-troop cap. If no matching attack occurs during the child's duration, the pending damage expires without consuming those modifiers or shields.-Renee's Nightmare Trace uses this form with a one-turn delay: the even-round Lancer attack locks the target and calculates the damage, then the following odd-round Lancer attack delivers it. The precise capture ordering and treatment of defensive state are provisional reverse-engineering assumptions, not confirmed game mechanics.+Renee's Nightmare Trace uses this form with a one-turn delay: the even-round Lancer attack locks the target and calculates the damage, then a matching allied attack on the following odd round delivers it. Its child uses `self.any` for delivery eligibility; the retained job still uses the original Lancer source and locked target, allowing delivery after the Lancers die. The precise capture ordering and treatment of defensive state remain provisional reverse-engineering assumptions.+Renee's Dreamslice mark increases both normal and skill damage taken by the marked target. Dreamcatcher's Lancer-specific increase remains restricted to normal damage.+All jobs emitted by one use read the parent extra-attack effect's same current `value`; its use/evolution is charged only after those jobs finish. Other attack-limited **modifier** effects are different: each generated skill job is a separate modifier use, and modifiers are charged after each job, so a one-job modifier can expire before the second target in the same extra attack is calculated.### `dodge`Index: simulator/src/damage.test.ts===================================================================--- simulator/src/damage.test.ts prev run+++ simulator/src/damage.test.ts this run@@ -106,8 +106,11 @@if (!group) {group = {ordinal: groups.length,bucketIndex: DYNAMIC_BUCKETS.findIndex((definition) => definition.name === activeEffect.intent.type),+ attackLimitedTakerModifier: activeEffect.kind === "modifier" &&+ activeEffect.duration.attacks !== undefined &&+ DYNAMIC_BUCKETS.find(definition => definition.name === activeEffect.intent.type)?.jobSide === "taker",sameEffectStacking: activeEffect.sameEffectStacking};byResolvedGroup.set(key, group);groups.push(group);Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -114,15 +114,17 @@primaryUsedEffects?: ActiveEffect[];minInitialArmy?: number;}-/** Borrowed during immediate delivery; retainGeneratedDamage owns delayed state. */+/** Immediate generation borrows scratch; captured generation owns its factors and static terms. */export interface GeneratedDamage {factors?: Float64Array;recording: DamageJobRecorder;armyTerm: number;damageBeforeOffsets: number;healthFactor: number;+ capturedDealerFactor?: number;+ capturedTakerFactor?: number;}export function generateDamageJob(job: DamageJob,@@ -158,19 +160,77 @@healthFactor: generated.healthFactor};}+/** Snapshot source/static/turn-based contributions; attack-limited taker modifiers wait for landing. */+export function generateCapturedDamageJob(+ job: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ options: DamageJobOptions+): GeneratedDamage {+ const recording = options.recorder.startDamageJob();+ const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();+ const usedEffects = options.usedEffects ?? [];+ const armyTerm = populateSourceBuckets(job, fighters, options, buckets);+ applyPartitionedBucketEffects(job, options.effectIndex, buckets, recording, usedEffects, options.primaryUsedEffects ?? usedEffects, false);+ const capturedDealerFactor = bucketSetValue(options.staticDamageProfile[job.dealerSide][job.dealerUnit].dealerFactor);+ const capturedTakerFactor = bucketSetValue(options.staticDamageProfile[job.takerSide][job.takerUnit].takerFactor);+ const expression = DYNAMIC_EXPRESSIONS[job.kind];+ const damageBeforeOffsets = multiplySlots(1, buckets.factors, expression.numeratorSlots) /+ multiplySlots(1, buckets.factors, expression.denominatorSlots) *+ capturedDealerFactor * capturedTakerFactor * bucketSetValue(DAMAGE_SCALE_BUCKET);+ return {+ factors: options.scratch ? buckets.factors.slice() : buckets.factors,+ recording,+ armyTerm,+ damageBeforeOffsets,+ healthFactor: dynamicHealthFactor(buckets.factors),+ capturedDealerFactor,+ capturedTakerFactor+ };+}++/** Add current taker attack budgets to the original buckets, then settle current shields and caps. */+export function deliverCapturedDamageJob(+ job: DamageJob,+ generated: GeneratedDamage,+ options: DamageJobOptions+): DamageResult {+ const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();+ buckets.factors.set(generated.factors!);+ const usedEffects = options.usedEffects ?? [];+ applyPartitionedBucketEffects(job, options.effectIndex, buckets, generated.recording, usedEffects, options.primaryUsedEffects ?? usedEffects, true);+ const expression = DYNAMIC_EXPRESSIONS[job.kind];+ // Re-evaluate, rather than dividing by captured factors: a captured bucket may be zero.+ const damageBeforeOffsets = multiplySlots(1, buckets.factors, expression.numeratorSlots) /+ multiplySlots(1, buckets.factors, expression.denominatorSlots) *+ generated.capturedDealerFactor! * generated.capturedTakerFactor! * bucketSetValue(DAMAGE_SCALE_BUCKET);+ return finishDamageJob(job, generated, options, buckets, damageBeforeOffsets, dynamicHealthFactor(buckets.factors));+}+export function deliverDamageJob(job: DamageJob,generated: GeneratedDamage,options: DamageJobOptions): DamageResult {- const { recording, armyTerm, damageBeforeOffsets, healthFactor } = generated;+ const { damageBeforeOffsets, healthFactor } = generated;const buckets = options.scratch ?? createNumericDamageBuckets();if (generated.factors !== buckets.factors) {resetDamageScratch(buckets);if (generated.factors) buckets.factors.set(generated.factors);}+ return finishDamageJob(job, generated, options, buckets, damageBeforeOffsets, healthFactor);+}++function finishDamageJob(+ job: DamageJob,+ generated: GeneratedDamage,+ options: DamageJobOptions,+ buckets: NumericDamageBuckets,+ damageBeforeOffsets: number,+ healthFactor: number+): DamageResult {+ const { recording, armyTerm } = generated;const usedEffects = options.usedEffects ?? [];const primaryUsedEffects = options.primaryUsedEffects ?? usedEffects;const offsetDamage = applyShields(job, options.effectIndex, buckets, recording, damageBeforeOffsets, healthFactor, usedEffects, primaryUsedEffects);const rawDamage = Math.max(0, damageBeforeOffsets - offsetDamage);@@ -187,17 +247,9 @@recording: DamageJobRecorder,usedEffects: ActiveEffect[],primaryUsedEffects: ActiveEffect[]): number {- // Fractional casualties carry between rounds, but every positive remainder is- // still one living troop and contributes fully to the next attack.- const dealerTroops = ceilIgnoringFloatResidue(- Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)- );- const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);- const armyTerm = Math.sqrt(dealerTroops) * Math.sqrt(initialArmy);- applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);- applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);+ const armyTerm = populateSourceBuckets(job, fighters, options, buckets);const jobSlot = damageJobSlot(job);applyBucketEffects(options.effectIndex.damageGroupsByJobShape[jobSlot],@@ -211,9 +263,50 @@);return armyTerm;}+function populateSourceBuckets(+ job: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ options: DamageJobOptions,+ buckets: NumericDamageBuckets+): number {+ // Fractional casualties carry between rounds, but every positive remainder is+ // still one living troop and contributes fully to the next attack.+ const dealerTroops = ceilIgnoringFloatResidue(+ Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)+ );+ const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);+ const armyTerm = Math.sqrt(dealerTroops) * Math.sqrt(initialArmy);+ applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);+ applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);+ return armyTerm;+}+function dynamicHealthFactor(factors: Float64Array): number {+ return factors[DYNAMIC_BUCKET_INDEX["active.hero.health.up"]] *+ factors[DYNAMIC_BUCKET_INDEX["active.troop.health.up"]] /+ factors[DYNAMIC_BUCKET_INDEX["active.hero.health.down"]] /+ factors[DYNAMIC_BUCKET_INDEX["active.troop.health.down"]];+}++function applyPartitionedBucketEffects(+ job: DamageJob,+ index: EffectIndex,+ buckets: NumericDamageBuckets,+ recording: DamageJobRecorder,+ usedEffects: ActiveEffect[],+ primaryUsedEffects: ActiveEffect[],+ attackLimitedTakerModifier: boolean+): void {+ const jobSlot = damageJobSlot(job);+ for (const group of index.damageGroupsByJobShape[jobSlot]) {+ if (group.attackLimitedTakerModifier !== attackLimitedTakerModifier) continue;+ applyEffectGroup(group, index.liveEffectsByGroup, job.round, jobSlot, buckets, recording, usedEffects, primaryUsedEffects, attackLimitedTakerModifier);+ }+}++function applyBucketEffects(groups: ActiveEffectGroup[],liveEffectsByGroup: ActiveEffect[][],round: number,@@ -235,12 +328,13 @@jobSlot: number,buckets: NumericDamageBuckets,recording: DamageJobRecorder,usedEffects: ActiveEffect[],- primaryUsedEffects: ActiveEffect[]+ primaryUsedEffects: ActiveEffect[],+ attackLimitedTakerModifier?: boolean): void {const dependency = group.requiredGroupOrdinalsByJobShape;- if (dependency && !requiredEffectIsApplicable(dependency, liveEffectsByGroup, round, jobSlot)) return;+ if (dependency && !requiredEffectIsApplicable(dependency, liveEffectsByGroup, round, jobSlot, attackLimitedTakerModifier)) return;const effects = liveEffectsByGroup[group.ordinal];if (effects.length === 0) return;if (group.sameEffectStacking !== "max") {for (const effect of effects) {@@ -322,14 +416,17 @@function requiredEffectIsApplicable(dependency: Array<number[] | undefined>,liveEffectsByGroup: ActiveEffect[][],round: number,- jobSlot: number+ jobSlot: number,+ attackLimitedTakerModifier?: boolean): boolean {const requiredOrdinals = dependency[jobSlot];if (!requiredOrdinals) return false;for (const ordinal of requiredOrdinals) {for (const effect of liveEffectsByGroup[ordinal]) {+ if (attackLimitedTakerModifier !== undefined &&+ effect.effectGroup!.attackLimitedTakerModifier !== attackLimitedTakerModifier) continue;if (isEffectAttackReady(effect) && effect.getCurrentValue(round) !== 0) return true;}}return false;Index: simulator/src/effectIndex.test.ts===================================================================--- simulator/src/effectIndex.test.ts prev run+++ simulator/src/effectIndex.test.ts this run@@ -121,8 +121,9 @@if (!group) {group = {ordinal: groups.length,bucketIndex: 0,+ attackLimitedTakerModifier: false,sameEffectStacking: effect.sameEffectStacking};byResolvedGroup.set(key, group);groups.push(group);Index: simulator/src/extraAttacks.ts===================================================================--- simulator/src/extraAttacks.ts prev run+++ simulator/src/extraAttacks.ts this run@@ -8,9 +8,16 @@UnitType} from "./types";import { UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";import { advanceEffectAttackDelay } from "./effects";-import { generateDamageJob, deliverDamageJob, retainGeneratedDamage, ceilIgnoringFloatResidue, type DamageJobOptions } from "./damage";+import {+ generateDamageJob,+ deliverDamageJob,+ generateCapturedDamageJob,+ deliverCapturedDamageJob,+ ceilIgnoringFloatResidue,+ type DamageJobOptions+} from "./damage";import { normalizeUnitType } from "./normalize";import {capJobToRemainingTarget,chargeEffectUse,@@ -22,16 +29,16 @@type RunLoopOptions,type Runtime} from "./runtime";-// Spawn, calculate, and charge extra attacks riding on one normal attack.-// Returns the finalized kills from every generated job in this normal attack's-// cluster. A triggered child can instead carry damage captured in an earlier round;-// shields are settled only when the child's locked normal attack matches.+// Pending jobs land before the matching normal attack is declared; immediate jobs+// run after its damage. Each effect participates in only one phase, so its attack+// delay advances once per matching normal attack. Only immediate skill kills feed+// that normal attack's deferred formulas.// The effect is charged one use only when at least one of its own jobs actually ran.-// Spawning reads only round-start state and the effect's own gates, so running earlier-// effects' jobs first cannot change what later effects spawn.+// Snapshot eligible effects before delivery because charging may expire them.export function processExtraAttacks(+ phase: "pending" | "immediate",normalAttack: DamageJob,intent: AttackIntent,runtime: Runtime,fighters: Record<SideId, ResolvedFighter>,@@ -46,25 +53,34 @@let totalKills = 0;let skillKills = 0;// Snapshot the applicable effects: charging an effect below may expire it out of the live index.const effects = runtime.effectIndex.extraAttacks.filter(- (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)+ (effect) =>+ (phase === "pending" ? (effect.pendingDamageJobs?.length ?? 0) > 0 : effect.pendingDamageJobs === undefined) &&+ extraAttackEffectAppliesToNormalAttack(effect, normalAttack) &&+ advanceEffectAttackDelay(effect));for (const effect of effects) {const sourceEffectId = effect.source.effectId ?? effect.intent.id;let processedJobCount = 0;let firstProcessedJob: DamageJob | undefined;if (effect.pendingDamageJobs) {- for (const pending of effect.pendingDamageJobs) {+ const pendingJobs = effect.pendingDamageJobs;+ let retainedJobCount = 0;+ for (const pending of pendingJobs) {const deliveryJob: DamageJob = {...pending.job,round,calculationRound: pending.job.round,roundStartTroops};- if (loopOptions.capRoundKills && targetExhausted(deliveryJob, roundStartTroops, roundTargetDamage)) continue;+ if (loopOptions.capRoundKills && targetExhausted(deliveryJob, roundStartTroops, roundTargetDamage)) {+ pendingJobs[retainedJobCount++] = pending;+ continue;+ }recorder.recordScheduledDamageJob(deliveryJob);- const result = deliverDamageJob(deliveryJob, pending.generated, damageJobOptions);+ const result = deliverCapturedDamageJob(deliveryJob, pending.generated, damageJobOptions);+ if (deliveryJob.kind === "skill") recorder.recordSkillDamageJob(deliveryJob, effect);if (loopOptions.capRoundKills) {capJobToRemainingTarget(result, deliveryJob, roundStartTroops, roundTargetDamage, recorder);} else if (loopOptions.capJobKills) {result.kills = Math.min(@@ -74,12 +90,17 @@recorder.recordFinalKills(result);}results.push({ job: deliveryJob, result, intent });totalKills += result.kills;+ if (deliveryJob.kind === "skill") {+ skillKills += result.kills;+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;+ }processedJobCount += 1;firstProcessedJob ??= deliveryJob;chargeUsedEffectsForJob(runtime, deliveryJob, recorder);}+ pendingJobs.length = retainedJobCount;} else {for (const definition of effect.triggerDamageJobs ?? []) {const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);@@ -160,9 +181,9 @@takerUnit: target.unit,sourceEffectId: effect.source.effectId ?? effect.intent.id,sourceMultiplier: multiplier};- const generated = retainGeneratedDamage(generateDamageJob(job, fighters, damageJobOptions));+ const generated = generateCapturedDamageJob(job, fighters, damageJobOptions);pending.push({ job, generated });chargeUsedEffectsForJob(runtime, job, damageJobOptions.recorder);}}Index: simulator/src/runtimeSkills.ts===================================================================--- simulator/src/runtimeSkills.ts prev run+++ simulator/src/runtimeSkills.ts this run@@ -141,9 +141,11 @@if (effect.sameEffectStacking === "max") assertDisjointResolvedGroupSlots(intent.id, slots, slotsForDefinition);const group: ActiveEffectGroup = {ordinal: effectGroups.length,bucketIndex: damageBucketIndex(definition.name),- sameEffectStacking: effect.sameEffectStacking+ sameEffectStacking: effect.sameEffectStacking,+ attackLimitedTakerModifier: effect.kind === "modifier" &&+ effect.duration.attacks !== undefined && definition.jobSide === "taker"};groupMetadata.set(group, {ownerSide: skill.side,effectId: intent.id,Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -64,8 +64,29 @@const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/renee_isolation_20260925/renee_mark_source_death.json", import.meta.url), "utf8"));assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), 184);});+test("Dream Mark capture leaves next-hit vulnerability for Marksmen and delivery precedes the next ordinary attack", () => {+ const input: BattleInput = {+ maxRounds: 3,+ attacker: {+ troops: { lancer_t6: 100000, marksman_t6: 100000 },+ heroes: { Renee: { skill_1: 4 }, Gwen: { skill_1: 3 } }+ },+ defender: { troops: { infantry_t6: 1000000 }, heroes: {} }+ };+ const result = runOnce(input, loadSimulatorConfig(), { mode: "trace" });+ const marksmanHit = result.attacks.find(attack =>+ attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "marksman"+ )!;+ assert.ok(marksmanHit.appliedEffects?.some(effect => effect.effectId === "EagleVision/1"));+ const nextTurn = result.attacks.filter(attack => attack.round === 3 && attack.dealerSide === "attacker");+ assert.equal(nextTurn[0].sourceEffectId, "NightmareTrace/1");+ assert.ok(nextTurn[0].appliedEffects?.some(effect => effect.effectId === "EagleVision/1"));+ assert.equal(nextTurn[1].sourceEffectId, undefined);+ assert.equal(nextTurn[1].appliedEffects?.some(effect => effect.effectId === "EagleVision/1"), false);+});+test("delayed damage settles shields on arrival in recorded Gatot battles", () => {const config = loadSimulatorConfig();for (const [path, survivors] of [["renee_s2_bucket_confirmations/gatot_shield_500_150.json", 157],Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -298,8 +298,13 @@cancelled.push({ intent, control });continue;}+ const pendingAttacks = processExtraAttacks("pending", job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {+ score += pendingAttacks.totalKills;+ }+const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)];const deferredEffects = matchingTriggerSkills@@ -327,9 +332,9 @@}captureTriggeredExtraDamage(triggeredChildren, job, fighters, damageJobOptions, runtime);advanceNormalAttackCounters(intent, runtime);- const extraAttacks = processExtraAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);+ const extraAttacks = processExtraAttacks("immediate", job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {score += extraAttacks.totalKills;}if (deferredEffects) {Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -289,8 +289,10 @@// Index into RuntimeSkills.effectGroups and every runtime's liveEffectsByGroup.ordinal: number;bucketIndex: number;sameEffectStacking: SameEffectStacking;+ /** Taker-side attack budgets are sampled when captured damage lands, not when it is generated. */+ attackLimitedTakerModifier: boolean;// Prepared dependency lookup indexed by damage-job shape. An empty slot means the// required effect cannot apply to that job, so this group must not contribute.requiredGroupOrdinalsByJobShape?: Array<number[] | undefined>;}
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@@ -102,7 +102,7 @@},"effects": {"BladeOfLight/1": {- "type": "active.hero.damage.up",+ "type": "extra_skill_attack","value": [12,24,@@ -115,11 +115,12 @@"infantry"]},- "duration": {- "attacks": {- "count": 1+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"}- }+ ]},"BladeOfLight/2": {"type": "active.hero.damageTaken.up",diff --git a/simulator/config/hero_definitions/Gordon.json b/simulator/config/hero_definitions/Gordon.json--- a/simulator/config/hero_definitions/Gordon.json+++ b/simulator/config/hero_definitions/Gordon.json@@ -22,8 +22,8 @@100],"units": {- "applies_to": "trigger.source",- "applies_vs": "trigger.target"+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"},"duration": {"attacks": {@@ -61,6 +61,7 @@"effects": {"ChemicalTerror/1": {"type": "active.hero.damage.up",+ "applies_to_damage_kinds": ["normal"],"value": [30,60,@@ -75,7 +76,8 @@},"duration": {"turns": {- "count": 1+ "count": 1,+ "delay": 1}},"same_effect_stacking": "max"@@ -99,6 +101,28 @@}},"same_effect_stacking": "max"+ },+ "ChemicalTerror/3": {+ "type": "active.hero.damage.up",+ "applies_to_damage_kinds": ["skill"],+ "value": [+ 30,+ 60,+ 90,+ 120,+ 150+ ],+ "units": {+ "applies_to": [+ "lancer"+ ]+ },+ "duration": {+ "turns": {+ "count": 1+ }+ },+ "same_effect_stacking": "max"}}},diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -24,7 +24,7 @@"type": "extra_skill_attack","value": [40, 80, 120, 160, 200],"units": {- "applies_to": "parent.use.source",+ "applies_to": "self.any","applies_vs": "parent.use.target"},"trigger_damage_jobs": [@@ -96,7 +96,6 @@"trigger_effects": {"Dreamslice/1": {"type": "active.hero.damageTaken.up",- "applies_to_damage_kinds": ["normal"],"value": [15, 30, 45, 60, 75],"units": {"applies_to": "parent.use.target",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@@ -21,6 +21,50 @@return runPrepared(prepareBattle(input, config), undefined, options);}+test("Gordon suppresses on third turns and boosts normal Lancer damage on the following turns", () => {+ const result = runOnce({+ maxRounds: 7,+ attacker: {+ troops: { lancer_t6: 1000 },+ heroes: { Gordon: { skill_1: 2, skill_2: 2, skill_3: 2 } }+ },+ defender: { troops: { lancer_t6: 1000 } }+ }, loadSimulatorConfig(), { mode: "standard" });++ assert.deepEqual(result.attacks+ .filter((attack) => attack.appliedEffects?.some((effect) => effect.effectId === "ChemicalTerror/1"))+ .map((attack) => attack.round), [4, 7]);+ assert.deepEqual(result.attacks+ .filter((attack) => attack.appliedEffects?.some((effect) => effect.effectId === "ChemicalTerror/2"))+ .map((attack) => attack.round), [3, 6]);+});++test("Gordon boosts Wayne skill damage on Chemical Terror's triggering turn", () => {+ const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/gordon_interactions_20260925/gordon_wayne_1200l_vs_4800l.json", import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), -313);+});++test("Ahmose Blade of Light deals separate skill damage with Bradley and against Wu Ming", () => {+ const config = loadSimulatorConfig();+ for (const [path, survivors] of [+ ["ahmose_bradley_1000i_vs_3700i", -77],+ ["ahmose_1000i_vs_wuming_1000i", -82]+ ] as const) {+ const [input] = JSON.parse(readFileSync(new URL(`../../testcases/emulator_verified/ahmose_isolation_20260925/${path}.json`, import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, config)), survivors, path);+ }+});++test("Renee Dreamslice amplifies allied skill damage against the marked target", () => {+ const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/ahmose_gordon_renee/ahmose_renee_vs_jasser_mark_kind.json", import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), 937);+});++test("Renee's captured Dream Mark survives the loss of its Lancer source", () => {+ const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/renee_isolation_20260925/renee_mark_source_death.json", import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), 184);+});+test("delayed damage settles shields on arrival in recorded Gatot battles", () => {const config = loadSimulatorConfig();for (const [path, survivors] of [
Run B 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@@ -102,7 +102,7 @@},"effects": {"BladeOfLight/1": {- "type": "active.hero.damage.up",+ "type": "extra_skill_attack","value": [12,24,@@ -115,11 +115,12 @@"infantry"]},- "duration": {- "attacks": {- "count": 1+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target"}- }+ ]},"BladeOfLight/2": {"type": "active.hero.damageTaken.up",diff --git a/simulator/config/hero_definitions/Gordon.json b/simulator/config/hero_definitions/Gordon.json--- a/simulator/config/hero_definitions/Gordon.json+++ b/simulator/config/hero_definitions/Gordon.json@@ -22,8 +22,8 @@100],"units": {- "applies_to": "trigger.source",- "applies_vs": "trigger.target"+ "applies_to": "trigger.source",+ "applies_vs": "trigger.target"},"duration": {"attacks": {@@ -61,6 +61,7 @@"effects": {"ChemicalTerror/1": {"type": "active.hero.damage.up",+ "applies_to_damage_kinds": ["normal"],"value": [30,60,@@ -75,7 +76,8 @@},"duration": {"turns": {- "count": 1+ "count": 1,+ "delay": 1}},"same_effect_stacking": "max"@@ -99,6 +101,28 @@}},"same_effect_stacking": "max"+ },+ "ChemicalTerror/3": {+ "type": "active.hero.damage.up",+ "applies_to_damage_kinds": ["skill"],+ "value": [+ 30,+ 60,+ 90,+ 120,+ 150+ ],+ "units": {+ "applies_to": [+ "lancer"+ ]+ },+ "duration": {+ "turns": {+ "count": 1+ }+ },+ "same_effect_stacking": "max"}}},diff --git a/simulator/config/hero_definitions/README.md b/simulator/config/hero_definitions/README.md--- a/simulator/config/hero_definitions/README.md+++ b/simulator/config/hero_definitions/README.md@@ -459,11 +459,13 @@The extra-attack effect is charged once for the parent normal attack if at least one of its jobs runs. If its `value` is non-positive, every job lacks living source/target troops, or every job is skipped because its target is already exhausted, the effect is not charged and can remain available.-Immediate and delayed hits share the same generation and delivery routines. Generation uses reusable scratch and the prepared modifier-group index to calculate non-shield damage. Delivery selects applicable shields from a small live list, consumes protection, and applies the current target-troop cap. Shield activation and expiry use the ordinary effect scheduler; shields retain group identity for stacking and dependencies but are excluded from the modifier job-shape lists.+Immediate and delayed hits share bucket evaluation, shield settlement, and target caps. Immediate hits calculate and consume their modifiers together. Delayed hits instead separate captured contributions from attack-limited target modifiers.-When an `extra_skill_attack` is materialized as a carrier's `trigger_effects` child, its generated damage is retained on that child ActiveEffect. Fast and standard modes retain scalar calculation state and the recording context; only trace mode copies the full bucket factors. Shields are not selected, advanced, or consumed during generation. The child can deliver its damage only when a later normal attack matches its resolved source and target scope, after that normal hit has resolved. Delivery uses the generation-time health conversion factor for shield-budget conversion without recalculating the damage. If the matching attack never occurs during the child's duration, the pending damage expires without consuming shields.+When an `extra_skill_attack` is materialized as a carrier's `trigger_effects` child, its generated damage is retained on that child ActiveEffect. All modes retain owned dynamic factors and captured static terms so delivery can combine modifiers without recalculating the source's troop strength. Attack-limited target modifiers and shields are not selected, advanced, or consumed during capture. When a later normal attack matches the child's source and target scope, pending damage lands before that attack is declared and before it creates new effects. Delivery applies the currently eligible attack-limited target modifiers to the captured buckets, consumes their uses, and settles live shields and the current target-troop cap. If no matching attack occurs during the child's duration, the pending damage expires without consuming those modifiers or shields.-Renee's Nightmare Trace uses this form with a one-turn delay: the even-round Lancer attack locks the target and calculates the damage, then the following odd-round Lancer attack delivers it. The precise capture ordering and treatment of defensive state are provisional reverse-engineering assumptions, not confirmed game mechanics.+Renee's Nightmare Trace uses this form with a one-turn delay: the even-round Lancer attack locks the target and calculates the damage, then a matching allied attack on the following odd round delivers it. Its child uses `self.any` for delivery eligibility; the retained job still uses the original Lancer source and locked target, allowing delivery after the Lancers die. The precise capture ordering and treatment of defensive state remain provisional reverse-engineering assumptions.++Renee's Dreamslice mark increases both normal and skill damage taken by the marked target. Dreamcatcher's Lancer-specific increase remains restricted to normal damage.All jobs emitted by one use read the parent extra-attack effect's same current `value`; its use/evolution is charged only after those jobs finish. Other attack-limited **modifier** effects are different: each generated skill job is a separate modifier use, and modifiers are charged after each job, so a one-job modifier can expire before the second target in the same extra attack is calculated.diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json--- a/simulator/config/hero_definitions/Renee.json+++ b/simulator/config/hero_definitions/Renee.json@@ -24,7 +24,7 @@"type": "extra_skill_attack","value": [40, 80, 120, 160, 200],"units": {- "applies_to": "parent.use.source",+ "applies_to": "self.any","applies_vs": "parent.use.target"},"trigger_damage_jobs": [@@ -96,7 +96,6 @@"trigger_effects": {"Dreamslice/1": {"type": "active.hero.damageTaken.up",- "applies_to_damage_kinds": ["normal"],"value": [15, 30, 45, 60, 75],"units": {"applies_to": "parent.use.target",diff --git a/simulator/src/damage.test.ts b/simulator/src/damage.test.ts--- a/simulator/src/damage.test.ts+++ b/simulator/src/damage.test.ts@@ -107,6 +107,9 @@group = {ordinal: groups.length,bucketIndex: DYNAMIC_BUCKETS.findIndex((definition) => definition.name === activeEffect.intent.type),+ attackLimitedTakerModifier: activeEffect.kind === "modifier" &&+ activeEffect.duration.attacks !== undefined &&+ DYNAMIC_BUCKETS.find(definition => definition.name === activeEffect.intent.type)?.jobSide === "taker",sameEffectStacking: activeEffect.sameEffectStacking};byResolvedGroup.set(key, group);diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -115,13 +115,15 @@minInitialArmy?: number;}-/** Borrowed during immediate delivery; retainGeneratedDamage owns delayed state. */+/** Immediate generation borrows scratch; captured generation owns its factors and static terms. */export interface GeneratedDamage {factors?: Float64Array;recording: DamageJobRecorder;armyTerm: number;damageBeforeOffsets: number;healthFactor: number;+ capturedDealerFactor?: number;+ capturedTakerFactor?: number;}export function generateDamageJob(@@ -159,17 +161,75 @@};}+/** Snapshot source/static/turn-based contributions; attack-limited taker modifiers wait for landing. */+export function generateCapturedDamageJob(+ job: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ options: DamageJobOptions+): GeneratedDamage {+ const recording = options.recorder.startDamageJob();+ const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();+ const usedEffects = options.usedEffects ?? [];+ const armyTerm = populateSourceBuckets(job, fighters, options, buckets);+ applyPartitionedBucketEffects(job, options.effectIndex, buckets, recording, usedEffects, options.primaryUsedEffects ?? usedEffects, false);+ const capturedDealerFactor = bucketSetValue(options.staticDamageProfile[job.dealerSide][job.dealerUnit].dealerFactor);+ const capturedTakerFactor = bucketSetValue(options.staticDamageProfile[job.takerSide][job.takerUnit].takerFactor);+ const expression = DYNAMIC_EXPRESSIONS[job.kind];+ const damageBeforeOffsets = multiplySlots(1, buckets.factors, expression.numeratorSlots) /+ multiplySlots(1, buckets.factors, expression.denominatorSlots) *+ capturedDealerFactor * capturedTakerFactor * bucketSetValue(DAMAGE_SCALE_BUCKET);+ return {+ factors: options.scratch ? buckets.factors.slice() : buckets.factors,+ recording,+ armyTerm,+ damageBeforeOffsets,+ healthFactor: dynamicHealthFactor(buckets.factors),+ capturedDealerFactor,+ capturedTakerFactor+ };+}++/** Add current taker attack budgets to the original buckets, then settle current shields and caps. */+export function deliverCapturedDamageJob(+ job: DamageJob,+ generated: GeneratedDamage,+ options: DamageJobOptions+): DamageResult {+ const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();+ buckets.factors.set(generated.factors!);+ const usedEffects = options.usedEffects ?? [];+ applyPartitionedBucketEffects(job, options.effectIndex, buckets, generated.recording, usedEffects, options.primaryUsedEffects ?? usedEffects, true);+ const expression = DYNAMIC_EXPRESSIONS[job.kind];+ // Re-evaluate, rather than dividing by captured factors: a captured bucket may be zero.+ const damageBeforeOffsets = multiplySlots(1, buckets.factors, expression.numeratorSlots) /+ multiplySlots(1, buckets.factors, expression.denominatorSlots) *+ generated.capturedDealerFactor! * generated.capturedTakerFactor! * bucketSetValue(DAMAGE_SCALE_BUCKET);+ return finishDamageJob(job, generated, options, buckets, damageBeforeOffsets, dynamicHealthFactor(buckets.factors));+}+export function deliverDamageJob(job: DamageJob,generated: GeneratedDamage,options: DamageJobOptions): DamageResult {- const { recording, armyTerm, damageBeforeOffsets, healthFactor } = generated;+ const { damageBeforeOffsets, healthFactor } = generated;const buckets = options.scratch ?? createNumericDamageBuckets();if (generated.factors !== buckets.factors) {resetDamageScratch(buckets);if (generated.factors) buckets.factors.set(generated.factors);}+ return finishDamageJob(job, generated, options, buckets, damageBeforeOffsets, healthFactor);+}++function finishDamageJob(+ job: DamageJob,+ generated: GeneratedDamage,+ options: DamageJobOptions,+ buckets: NumericDamageBuckets,+ damageBeforeOffsets: number,+ healthFactor: number+): DamageResult {+ const { recording, armyTerm } = generated;const usedEffects = options.usedEffects ?? [];const primaryUsedEffects = options.primaryUsedEffects ?? usedEffects;const offsetDamage = applyShields(job, options.effectIndex, buckets, recording, damageBeforeOffsets, healthFactor, usedEffects, primaryUsedEffects);@@ -188,15 +248,7 @@usedEffects: ActiveEffect[],primaryUsedEffects: ActiveEffect[]): number {- // Fractional casualties carry between rounds, but every positive remainder is- // still one living troop and contributes fully to the next attack.- const dealerTroops = ceilIgnoringFloatResidue(- Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)- );- const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);- const armyTerm = Math.sqrt(dealerTroops) * Math.sqrt(initialArmy);- applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);- applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);+ const armyTerm = populateSourceBuckets(job, fighters, options, buckets);const jobSlot = damageJobSlot(job);applyBucketEffects(@@ -212,6 +264,47 @@return armyTerm;}+function populateSourceBuckets(+ job: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ options: DamageJobOptions,+ buckets: NumericDamageBuckets+): number {+ // Fractional casualties carry between rounds, but every positive remainder is+ // still one living troop and contributes fully to the next attack.+ const dealerTroops = ceilIgnoringFloatResidue(+ Math.max(0, job.roundStartTroops[job.dealerSide][job.dealerUnit] ?? 0)+ );+ const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);+ const armyTerm = Math.sqrt(dealerTroops) * Math.sqrt(initialArmy);+ applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);+ applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);+ return armyTerm;+}++function dynamicHealthFactor(factors: Float64Array): number {+ return factors[DYNAMIC_BUCKET_INDEX["active.hero.health.up"]] *+ factors[DYNAMIC_BUCKET_INDEX["active.troop.health.up"]] /+ factors[DYNAMIC_BUCKET_INDEX["active.hero.health.down"]] /+ factors[DYNAMIC_BUCKET_INDEX["active.troop.health.down"]];+}++function applyPartitionedBucketEffects(+ job: DamageJob,+ index: EffectIndex,+ buckets: NumericDamageBuckets,+ recording: DamageJobRecorder,+ usedEffects: ActiveEffect[],+ primaryUsedEffects: ActiveEffect[],+ attackLimitedTakerModifier: boolean+): void {+ const jobSlot = damageJobSlot(job);+ for (const group of index.damageGroupsByJobShape[jobSlot]) {+ if (group.attackLimitedTakerModifier !== attackLimitedTakerModifier) continue;+ applyEffectGroup(group, index.liveEffectsByGroup, job.round, jobSlot, buckets, recording, usedEffects, primaryUsedEffects, attackLimitedTakerModifier);+ }+}+function applyBucketEffects(groups: ActiveEffectGroup[],@@ -236,10 +329,11 @@buckets: NumericDamageBuckets,recording: DamageJobRecorder,usedEffects: ActiveEffect[],- primaryUsedEffects: ActiveEffect[]+ primaryUsedEffects: ActiveEffect[],+ attackLimitedTakerModifier?: boolean): void {const dependency = group.requiredGroupOrdinalsByJobShape;- if (dependency && !requiredEffectIsApplicable(dependency, liveEffectsByGroup, round, jobSlot)) return;+ if (dependency && !requiredEffectIsApplicable(dependency, liveEffectsByGroup, round, jobSlot, attackLimitedTakerModifier)) return;const effects = liveEffectsByGroup[group.ordinal];if (effects.length === 0) return;if (group.sameEffectStacking !== "max") {@@ -323,12 +417,15 @@dependency: Array<number[] | undefined>,liveEffectsByGroup: ActiveEffect[][],round: number,- jobSlot: number+ jobSlot: number,+ attackLimitedTakerModifier?: boolean): boolean {const requiredOrdinals = dependency[jobSlot];if (!requiredOrdinals) return false;for (const ordinal of requiredOrdinals) {for (const effect of liveEffectsByGroup[ordinal]) {+ if (attackLimitedTakerModifier !== undefined &&+ effect.effectGroup!.attackLimitedTakerModifier !== attackLimitedTakerModifier) continue;if (isEffectAttackReady(effect) && effect.getCurrentValue(round) !== 0) return true;}}diff --git a/simulator/src/effectIndex.test.ts b/simulator/src/effectIndex.test.ts--- a/simulator/src/effectIndex.test.ts+++ b/simulator/src/effectIndex.test.ts@@ -122,6 +122,7 @@group = {ordinal: groups.length,bucketIndex: 0,+ attackLimitedTakerModifier: false,sameEffectStacking: effect.sameEffectStacking};byResolvedGroup.set(key, group);diff --git a/simulator/src/extraAttacks.ts b/simulator/src/extraAttacks.ts--- a/simulator/src/extraAttacks.ts+++ b/simulator/src/extraAttacks.ts@@ -9,7 +9,14 @@} from "./types";import { UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";import { advanceEffectAttackDelay } from "./effects";-import { generateDamageJob, deliverDamageJob, retainGeneratedDamage, ceilIgnoringFloatResidue, type DamageJobOptions } from "./damage";+import {+ generateDamageJob,+ deliverDamageJob,+ generateCapturedDamageJob,+ deliverCapturedDamageJob,+ ceilIgnoringFloatResidue,+ type DamageJobOptions+} from "./damage";import { normalizeUnitType } from "./normalize";import {capJobToRemainingTarget,@@ -23,14 +30,14 @@type Runtime} from "./runtime";-// Spawn, calculate, and charge extra attacks riding on one normal attack.-// Returns the finalized kills from every generated job in this normal attack's-// cluster. A triggered child can instead carry damage captured in an earlier round;-// shields are settled only when the child's locked normal attack matches.+// Pending jobs land before the matching normal attack is declared; immediate jobs+// run after its damage. Each effect participates in only one phase, so its attack+// delay advances once per matching normal attack. Only immediate skill kills feed+// that normal attack's deferred formulas.// The effect is charged one use only when at least one of its own jobs actually ran.-// Spawning reads only round-start state and the effect's own gates, so running earlier-// effects' jobs first cannot change what later effects spawn.+// Snapshot eligible effects before delivery because charging may expire them.export function processExtraAttacks(+ phase: "pending" | "immediate",normalAttack: DamageJob,intent: AttackIntent,runtime: Runtime,@@ -47,23 +54,32 @@let skillKills = 0;// Snapshot the applicable effects: charging an effect below may expire it out of the live index.const effects = runtime.effectIndex.extraAttacks.filter(- (effect) => extraAttackEffectAppliesToNormalAttack(effect, normalAttack) && advanceEffectAttackDelay(effect)+ (effect) =>+ (phase === "pending" ? (effect.pendingDamageJobs?.length ?? 0) > 0 : effect.pendingDamageJobs === undefined) &&+ extraAttackEffectAppliesToNormalAttack(effect, normalAttack) &&+ advanceEffectAttackDelay(effect));for (const effect of effects) {const sourceEffectId = effect.source.effectId ?? effect.intent.id;let processedJobCount = 0;let firstProcessedJob: DamageJob | undefined;if (effect.pendingDamageJobs) {- for (const pending of effect.pendingDamageJobs) {+ const pendingJobs = effect.pendingDamageJobs;+ let retainedJobCount = 0;+ for (const pending of pendingJobs) {const deliveryJob: DamageJob = {...pending.job,round,calculationRound: pending.job.round,roundStartTroops};- if (loopOptions.capRoundKills && targetExhausted(deliveryJob, roundStartTroops, roundTargetDamage)) continue;+ if (loopOptions.capRoundKills && targetExhausted(deliveryJob, roundStartTroops, roundTargetDamage)) {+ pendingJobs[retainedJobCount++] = pending;+ continue;+ }recorder.recordScheduledDamageJob(deliveryJob);- const result = deliverDamageJob(deliveryJob, pending.generated, damageJobOptions);+ const result = deliverCapturedDamageJob(deliveryJob, pending.generated, damageJobOptions);+ if (deliveryJob.kind === "skill") recorder.recordSkillDamageJob(deliveryJob, effect);if (loopOptions.capRoundKills) {capJobToRemainingTarget(result, deliveryJob, roundStartTroops, roundTargetDamage, recorder);} else if (loopOptions.capJobKills) {@@ -75,10 +91,15 @@}results.push({ job: deliveryJob, result, intent });totalKills += result.kills;+ if (deliveryJob.kind === "skill") {+ skillKills += result.kills;+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;+ }processedJobCount += 1;firstProcessedJob ??= deliveryJob;chargeUsedEffectsForJob(runtime, deliveryJob, recorder);}+ pendingJobs.length = retainedJobCount;} else {for (const definition of effect.triggerDamageJobs ?? []) {const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);@@ -161,7 +182,7 @@sourceEffectId: effect.source.effectId ?? effect.intent.id,sourceMultiplier: multiplier};- const generated = retainGeneratedDamage(generateDamageJob(job, fighters, damageJobOptions));+ const generated = generateCapturedDamageJob(job, fighters, damageJobOptions);pending.push({ job, generated });chargeUsedEffectsForJob(runtime, job, damageJobOptions.recorder);}diff --git a/simulator/src/runtimeSkills.ts b/simulator/src/runtimeSkills.ts--- a/simulator/src/runtimeSkills.ts+++ b/simulator/src/runtimeSkills.ts@@ -142,7 +142,9 @@const group: ActiveEffectGroup = {ordinal: effectGroups.length,bucketIndex: damageBucketIndex(definition.name),- sameEffectStacking: effect.sameEffectStacking+ sameEffectStacking: effect.sameEffectStacking,+ attackLimitedTakerModifier: effect.kind === "modifier" &&+ effect.duration.attacks !== undefined && definition.jobSide === "taker"};groupMetadata.set(group, {ownerSide: skill.side,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@@ -21,6 +21,71 @@return runPrepared(prepareBattle(input, config), undefined, options);}+test("Gordon suppresses on third turns and boosts normal Lancer damage on the following turns", () => {+ const result = runOnce({+ maxRounds: 7,+ attacker: {+ troops: { lancer_t6: 1000 },+ heroes: { Gordon: { skill_1: 2, skill_2: 2, skill_3: 2 } }+ },+ defender: { troops: { lancer_t6: 1000 } }+ }, loadSimulatorConfig(), { mode: "standard" });++ assert.deepEqual(result.attacks+ .filter((attack) => attack.appliedEffects?.some((effect) => effect.effectId === "ChemicalTerror/1"))+ .map((attack) => attack.round), [4, 7]);+ assert.deepEqual(result.attacks+ .filter((attack) => attack.appliedEffects?.some((effect) => effect.effectId === "ChemicalTerror/2"))+ .map((attack) => attack.round), [3, 6]);+});++test("Gordon boosts Wayne skill damage on Chemical Terror's triggering turn", () => {+ const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/gordon_interactions_20260925/gordon_wayne_1200l_vs_4800l.json", import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), -313);+});++test("Ahmose Blade of Light deals separate skill damage with Bradley and against Wu Ming", () => {+ const config = loadSimulatorConfig();+ for (const [path, survivors] of [+ ["ahmose_bradley_1000i_vs_3700i", -77],+ ["ahmose_1000i_vs_wuming_1000i", -82]+ ] as const) {+ const [input] = JSON.parse(readFileSync(new URL(`../../testcases/emulator_verified/ahmose_isolation_20260925/${path}.json`, import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, config)), survivors, path);+ }+});++test("Renee Dreamslice amplifies allied skill damage against the marked target", () => {+ const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/ahmose_gordon_renee/ahmose_renee_vs_jasser_mark_kind.json", import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), 937);+});++test("Renee's captured Dream Mark survives the loss of its Lancer source", () => {+ const [input] = JSON.parse(readFileSync(new URL("../../testcases/emulator_verified/renee_isolation_20260925/renee_mark_source_death.json", import.meta.url), "utf8"));+ assert.equal(signedRemainingScore(runOnce(input, loadSimulatorConfig())), 184);+});++test("Dream Mark capture leaves next-hit vulnerability for Marksmen and delivery precedes the next ordinary attack", () => {+ const input: BattleInput = {+ maxRounds: 3,+ attacker: {+ troops: { lancer_t6: 100000, marksman_t6: 100000 },+ heroes: { Renee: { skill_1: 4 }, Gwen: { skill_1: 3 } }+ },+ defender: { troops: { infantry_t6: 1000000 }, heroes: {} }+ };+ const result = runOnce(input, loadSimulatorConfig(), { mode: "trace" });+ const marksmanHit = result.attacks.find(attack =>+ attack.round === 2 && attack.dealerSide === "attacker" && attack.dealerUnit === "marksman"+ )!;+ assert.ok(marksmanHit.appliedEffects?.some(effect => effect.effectId === "EagleVision/1"));+ const nextTurn = result.attacks.filter(attack => attack.round === 3 && attack.dealerSide === "attacker");+ assert.equal(nextTurn[0].sourceEffectId, "NightmareTrace/1");+ assert.ok(nextTurn[0].appliedEffects?.some(effect => effect.effectId === "EagleVision/1"));+ assert.equal(nextTurn[1].sourceEffectId, undefined);+ assert.equal(nextTurn[1].appliedEffects?.some(effect => effect.effectId === "EagleVision/1"), false);+});+test("delayed damage settles shields on arrival in recorded Gatot battles", () => {const config = loadSimulatorConfig();for (const [path, survivors] of [diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -299,6 +299,11 @@continue;}+ const pendingAttacks = processExtraAttacks("pending", job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);+ if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {+ score += pendingAttacks.totalKills;+ }+const matchingTriggerSkills = runtime.skills.attackDeclaredByJobShape[damageJobShapeSlot("normal", intent.dealerSide, intent.dealerUnit, intent.takerSide, intent.takerUnit)];@@ -328,7 +333,7 @@captureTriggeredExtraDamage(triggeredChildren, job, fighters, damageJobOptions, runtime);advanceNormalAttackCounters(intent, runtime);- const extraAttacks = processExtraAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);+ const extraAttacks = processExtraAttacks("immediate", job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {score += extraAttacks.totalKills;}diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -290,6 +290,8 @@ordinal: number;bucketIndex: number;sameEffectStacking: SameEffectStacking;+ /** Taker-side attack budgets are sampled when captured damage lands, not when it is generated. */+ attackLimitedTakerModifier: boolean;// Prepared dependency lookup indexed by damage-job shape. An empty slot means the// required effect cannot apply to that job, so this group must not contribute.requiredGroupOrdinalsByJobShape?: Array<number[] | undefined>;