← Back to Runs
Compare Runs
A (baseline)
B (current)
Avg Error A0.18%
Avg Error B0.19%
Δ Avg Error+0.02%
Changed70
Improved33
Regressed36
Added1
Retired0
Testcase Delta
353 / 353
Code / Config Changes
Commits (71303048 → ce9afe1e)
ce9afe1eBetter handling of random extra backgroud etcpiddlyminx29/08/2026, 02:07:28
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@@ -209,13 +209,13 @@| --- | --- | --- || `type` | One supported modifier/special-effect string, or omitted on a child-bearing carrier | Chooses the mechanic and, for modifiers, the damage-equation bucket. || `value` | Usually a number or per-level numeric array | Percentage magnitude for modifiers; damage multiplier percentage for `extra_skill_attack`; fixed unit order for `attack_order`. || `units` | `{ applies_to?, applies_vs? }` | Resolves which troop lines may receive/use the effect and which opposing troop lines it applies against. |-| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means permanent, except that `extra_skill_attack` defaults to one turn and one attack. |+| `duration` | `{ turns?, attacks? }` | Optional round window and/or use limit. Omitted means permanent, except that extra-attack effects default to one turn and one attack. || `same_effect_stacking` | `add` or `max` | Controls overlap between live activations of the same modifier definition and scope. Omitted means `add`. || `requires_effect` | Effect ID string | Applies a runtime damage modifier only while the named effect is applicable to the same damage job. || `value_evolution` | Evolution object | Optionally changes the effect value as rounds or uses advance. |-| `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the actual skill-damage jobs it emits. |+| `trigger_damage_jobs` | Non-empty array of job definitions | Required for `extra_skill_attack`; describes the damage jobs it emits. || `trigger_effects` | Object keyed by child effect ID | Defines children materialized only from an actual parent use. |### `value` and level selection@@ -226,9 +226,9 @@```At level 3 this resolves to 15. A scalar number gives the same value at every level. Numeric arrays are selected by level before effects reach the runtime.-`dodge` and `no_attack` ignore `value`; their probability belongs on the skill trigger. `extra_skill_attack` is not covered by the percentage-bucket value validator: its resolved `value` is converted to a number, and a missing, non-finite, zero, or negative value produces no damage jobs.+`dodge` and `no_attack` ignore `value`; their probability belongs on the skill trigger. Extra-attack effects are not covered by the percentage-bucket value validator: their resolved `value` is converted to a number, and a missing, non-finite, zero, or negative value produces no damage jobs.## Modifier effect typesModifier names identify a damage-equation bucket. The general forms are:@@ -404,13 +404,13 @@## Special effect types### `extra_skill_attack`-This creates an active effect that can be used by eligible normal attacks. `units.applies_to`/`applies_vs` gate the parent normal attack; they do not by themselves define the generated jobs. After the parent normal job is calculated, each `trigger_damage_jobs` entry expands into zero or more immediate `kind: "skill"` jobs.+This creates an active effect that can be used by eligible normal attacks. `units.applies_to`/`applies_vs` gate the parent normal attack; they do not by themselves define the generated jobs. After the parent normal job is calculated, each `trigger_damage_jobs` entry expands into zero or more generated jobs.When `duration` is omitted, the active effect defaults to one turn and one applicable attack. Declare `duration` only when the extra attack needs a different lifetime or delay.-`value` becomes the generated job's source multiplier: `100` means `1.0`, `25` means `0.25`, and `200` means `2.0`. The result is a **new full damage calculation**, not that percentage of the parent normal job's final kills or raw damage. It uses the generated source/target pair, round-start troop counts, the ordinary static factors, general active modifiers, and skill-only type modifiers. Normal-only type modifiers do not apply.+`value` becomes the generated job's source multiplier: `100` means `1.0`, `25` means `0.25`, and `200` means `2.0`. The result is a **new full damage calculation**, not that percentage of the parent normal job's final kills or raw damage. The multiplier is independent of `damage_kind`; that field alone decides whether normal-only, skill-only, or neither family of type modifiers applies.Requirements:- `trigger_damage_jobs` must be a non-empty array;@@ -428,8 +428,10 @@"target": "enemy.living"}```+An optional `damage_kind` may be `normal`, `skill`, or `extra`, and defaults to `skill`. This is the only field that classifies the generated damage. `extra` receives common damage buckets but neither normal-only nor skill-only buckets, and it is not attributed to `skillKills`.+Supported `source` and `target` selectors are:| Selector | Meaning || --- | --- |@@ -447,8 +449,12 @@The job selector determines the actual side as well as the unit. The runtime does not enforce that a generated source and target are opponents, so scopes misconfigured onto the same side can generate friendly-fire or same-side jobs.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.+When an `extra_skill_attack` is materialized as a carrier's `trigger_effects` child, its completed damage result is captured in the carrier-use round and stored on that child ActiveEffect. The child can deliver that fixed result only when a later normal attack matches its resolved source and target scope. Delivery does not run the damage equation again; if the matching attack never occurs during the child's duration, the pending damage expires with the effect.++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.+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/config/hero_definitions/Renee.json===================================================================--- simulator/config/hero_definitions/Renee.json prev run+++ simulator/config/hero_definitions/Renee.json this run@@ -20,19 +20,25 @@"attacks": { "count": 1 }},"trigger_effects": {"NightmareTrace/1": {- "type": "active.hero.damageTaken.up",+ "type": "extra_skill_attack","value": [40, 80, 120, 160, 200],"units": {- "applies_to": "parent.use.target",- "applies_vs": "parent.use.source"+ "applies_to": "parent.use.source",+ "applies_vs": "parent.use.target"},+ "trigger_damage_jobs": [+ {+ "source": "use.source",+ "target": "use.target",+ "damage_kind": "normal"+ }+ ],"duration": {"turns": { "delay": 1, "count": 1 },"attacks": { "count": 1 }- },- "same_effect_stacking": "max"+ }}}}}@@ -54,13 +60,13 @@"attacks": { "count": 1 }},"trigger_effects": {"Dreamcatcher/1": {- "type": "active.hero.damageTaken.up",+ "type": "type.marked_target.damage.up","value": [30, 60, 90, 120, 150],"units": {- "applies_to": "parent.use.target",- "applies_vs": "parent.use.source"+ "applies_to": "parent.use.source",+ "applies_vs": "parent.use.target"},"duration": {"turns": { "delay": 1, "count": 1 }},@@ -87,13 +93,13 @@"attacks": { "count": 1 }},"trigger_effects": {"Dreamslice/1": {- "type": "active.hero.damageTaken.up",+ "type": "type.marked_target.damage.up","value": [15, 30, 45, 60, 75],"units": {- "applies_to": "parent.use.target",- "applies_vs": "any"+ "applies_to": "all",+ "applies_vs": "parent.use.target"},"duration": {"turns": { "delay": 1, "count": 1 }},Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -293,8 +293,56 @@assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key multiplier/i);});+test("loadSimulatorConfig accepts extra damage jobs and rejects removed delay metadata or unknown kinds", () => {+ const validExtra = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }]+ });+ const removedDelay = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", delivery_delay_turns: 1 }]+ });+ const invalidKind = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "mystery" }]+ });++ assert.doesNotThrow(() => loadSimulatorConfigFromDir(validExtra));+ assert.throws(() => loadSimulatorConfigFromDir(removedDelay), /unknown trigger_damage_jobs key delivery_delay_turns/i);+ assert.throws(() => loadSimulatorConfigFromDir(invalidKind), /damage_kind.*normal.*skill.*extra/i);+});++test("loadSimulatorConfig reports the removed extra_attack effect type as unsupported", () => {+ const root = writeConfigWithTroopEffect({+ type: "extra_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }]+ });++ const config = loadSimulatorConfigFromDir(root);+ assert.equal(config.diagnostics.unsupportedEffects.some((effect) => effect.type === "extra_attack"), true);+});++test("loadSimulatorConfig rejects per-job skill reporting overrides", () => {+ const root = writeConfigWithTroopEffect({+ type: "extra_skill_attack",+ value: 100,+ units: { applies_to: "trigger.source", applies_vs: "trigger.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", reports_skill_kills: false } as never]+ });++ assert.throws(() => loadSimulatorConfigFromDir(root), /unknown trigger_damage_jobs key reports_skill_kills/i);+});+test("loadSimulatorConfig requires trigger_damage_jobs source and target", () => {const missingSourceRoot = writeConfigWithTroopEffect({type: "extra_skill_attack",value: 100,Index: simulator/src/config.ts===================================================================--- simulator/src/config.ts prev run+++ simulator/src/config.ts this run@@ -430,9 +430,9 @@function validateTriggerDamageJobShape(job: unknown, path: string, jobIndex: number): asserts job is TriggerDamageJobDefinition {if (!job || typeof job !== "object" || Array.isArray(job)) {throw new Error(`trigger_damage_jobs entry must be an object at ${path}.trigger_damage_jobs[${jobIndex}]`);}- const allowedKeys = new Set(["source", "target"]);+ const allowedKeys = new Set(["source", "target", "damage_kind"]);for (const key of Object.keys(job)) {if (!allowedKeys.has(key)) {throw new Error(`unknown trigger_damage_jobs key ${key} at ${path}.trigger_damage_jobs[${jobIndex}]`);}@@ -443,8 +443,11 @@}if (record.target === undefined) {throw new Error(`trigger_damage_jobs entry requires target at ${path}.trigger_damage_jobs[${jobIndex}]`);}+ if (record.damage_kind !== undefined && record.damage_kind !== "normal" && record.damage_kind !== "skill" && record.damage_kind !== "extra") {+ throw new Error(`trigger_damage_jobs damage_kind must be "normal", "skill", or "extra" at ${path}.trigger_damage_jobs[${jobIndex}]`);+ }}function validateTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"],Index: simulator/src/damage.test.ts===================================================================--- simulator/src/damage.test.ts prev run+++ simulator/src/damage.test.ts this run@@ -169,8 +169,21 @@assert.ok(Math.abs((outcome.trace?.rawDamage ?? 0) - 12.3456) < 1e-12);assert.ok(Math.abs(outcome.kills - 12.3456) < 1e-12);});+test("generated-job source multiplier is independent of damage kind", () => {+ for (const kind of ["normal", "skill", "extra"] as const) {+ const outcome = calculateIndexedDamageJob(+ { ...job, kind, sourceMultiplier: 1.6 },+ simpleFighters(),+ [],+ { trace: true, minInitialArmy: 1000, capToTakerTroops: false }+ );++ assert.ok(Math.abs(outcome.kills - 16) < 1e-12, kind);+ }+});+test("damage calculation does not quantize values near the three-decimal grid", () => {const outcome = calculateIndexedDamageJob({ ...job, kind: "skill", sourceMultiplier: 2.007 },simpleFighters(),@@ -451,9 +464,9 @@assert.ok(Math.abs(damageTakenUp.kills - baseline.kills * 1.25) < 1e-12);assert.ok(Math.abs(damageTakenDown.kills - baseline.kills / 1.25) < 1e-12);});-test("a durationless shield subtracts its full raw value from every normal and skill damage job", () => {+test("a durationless shield subtracts its full raw value from normal, skill, and extra damage jobs", () => {const fighters = simpleFighters();const shield = {...effect("active.hero.shield", "defender", 25),kind: "shield" as const,@@ -465,12 +478,16 @@const secondNormal = calculateIndexedDamageJob(job, fighters, [shield], { trace: true });const skillJob = { ...job, kind: "skill" as const, sourceMultiplier: 1 };const skillBaseline = calculateIndexedDamageJob(skillJob, fighters, [], { trace: true });const skill = calculateIndexedDamageJob(skillJob, fighters, [shield], { trace: true });+ const extraJob = { ...job, kind: "extra" as const, sourceMultiplier: 1 };+ const extraBaseline = calculateIndexedDamageJob(extraJob, fighters, [], { trace: true });+ const extra = calculateIndexedDamageJob(extraJob, fighters, [shield], { trace: true });assert.equal(firstNormal.kills, Math.max(0, normalBaseline.kills - 25));assert.equal(secondNormal.kills, firstNormal.kills);assert.equal(skill.kills, Math.max(0, skillBaseline.kills - 25));+ assert.equal(extra.kills, Math.max(0, extraBaseline.kills - 25));assert.equal(firstNormal.trace?.damageBeforeOffsets, normalBaseline.kills);assert.equal(firstNormal.trace?.offsetDamage, 25);assert.equal(firstNormal.trace?.aggregationGroups["active.hero.shield"].placement, "post_subtract");assert.deepEqual(firstNormal.appliedEffects, [{@@ -591,21 +608,26 @@assert.equal(outcome.trace?.aggregationGroups["passive.taker.health.down"].placement, "numerator");assert.equal(Number((outcome.kills / baseline.kills).toFixed(6)), 2.05);});-test("pass-specific buckets only apply to matching damage job kind", () => {+test("normal- and skill-specific buckets exclude extra damage", () => {const normalEffect = effect("type.normal.damage.up", "attacker", 100);const skillEffect = effect("type.skill.damage.up", "attacker", 100);const fighters = simpleFighters();const normalOutcome = calculateIndexedDamageJob(job, fighters, [normalEffect, skillEffect], { trace: true });const skillOutcome = calculateIndexedDamageJob({ ...job, kind: "skill", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {trace: true});+ const extraOutcome = calculateIndexedDamageJob({ ...job, kind: "extra", sourceMultiplier: 1 }, fighters, [normalEffect, skillEffect], {+ trace: true+ });assert.equal(normalOutcome.trace?.atomicBuckets["type.normal.damage.up"].totalPct, 100);assert.equal(normalOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);+ assert.equal(extraOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);+ assert.equal(extraOutcome.trace?.aggregationGroups["type.skill.damage.up"], undefined);});test("attack-duration bucket effects are charged by the applicable attack job", () => {const oneAttackEffect = {Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -3,8 +3,9 @@ActiveEffectGroup,AppliedEffect,DamageEquationTrace,DamageJob,+ DamageResult,ResolvedFighter,SideId,UnitType} from "./types";@@ -67,19 +68,16 @@// Lean result of a single damage job: kills plus whatever the selected recorder requested.// Usage charging happens via options.usedEffects (every mode); the heavy per-attack// AttackOutcome is assembled by the recorder, not here.-export interface DamageResult {- kills: number;- appliedEffects?: AppliedEffect[];- trace?: DamageEquationTrace;-}+export type { DamageResult } from "./types";const DYNAMIC_FACTOR_TERMS = compileDamageTerms(DYNAMIC_BUCKETS);const STATIC_FACTOR_TERMS = compileDamageTerms(STATIC_BUCKETS);const DYNAMIC_EXPRESSIONS = {normal: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "normal" }),- skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" })+ skill: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "skill" }),+ extra: compileDamageExpression(DYNAMIC_FACTOR_TERMS, { kind: "extra" })};const STATIC_EXPRESSIONS = {dealer: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "dealer" }),taker: compileDamageExpression(STATIC_FACTOR_TERMS, { jobSide: "taker" })@@ -139,9 +137,9 @@const initialArmy = options.minInitialArmy ?? minInitialArmy(fighters);const armyTerm = ceilIgnoringFloatResidue(Math.sqrt(dealerTroops) * Math.sqrt(initialArmy));const buckets = options.scratch ? resetDamageScratch(options.scratch) : createNumericDamageBuckets();applyDynamicDamageBucketValue(buckets, "troops.count", armyTerm);- applyDynamicDamageBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);+ applyDynamicDamageBucketValue(buckets, "source.multiplier", job.sourceMultiplier ?? 1);const usedEffects = options.usedEffects ?? [];const primaryUsedEffects = options.primaryUsedEffects ?? usedEffects;const jobSlot = damageJobSlot(job);Index: simulator/src/damageBuckets.ts===================================================================--- simulator/src/damageBuckets.ts prev run+++ simulator/src/damageBuckets.ts this run@@ -61,14 +61,15 @@{ name: "active.troop.defense.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },{ name: "active.troop.health.down", jobSide: "taker", update: "add_pct_factor", placement: "numerator", effectBucket: true },{ name: "active.troop.health.up", jobSide: "taker", update: "add_pct_factor", placement: "denominator", effectBucket: true },{ name: "active.troop.shield", jobSide: "taker", update: "add_raw", placement: "post_subtract", effectBucket: true },- { name: "source.extraSkill", jobSide: "dealer", update: "assign_factor", placement: "numerator", damageKind: "skill" },+ { name: "source.multiplier", jobSide: "dealer", update: "assign_factor", placement: "numerator" },{ name: "troops.count", jobSide: "dealer", update: "assign_factor", placement: "numerator" },{ name: "type.all.damage.down", jobSide: "dealer", update: "multiply_pct_factor", placement: "denominator", effectBucket: true },{ name: "type.all.damage.up", jobSide: "dealer", update: "multiply_pct_factor", placement: "numerator", effectBucket: true },{ name: "type.single_target.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", effectBucket: true },{ name: "type.single_target.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },+ { name: "type.marked_target.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", effectBucket: true },{ name: "type.normal.damage.down", jobSide: "dealer", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },{ name: "type.normal.damage.up", jobSide: "dealer", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },{ name: "type.normal.damageTaken.down", jobSide: "taker", update: "add_pct_factor", placement: "denominator", damageKind: "normal", effectBucket: true },{ name: "type.normal.damageTaken.up", jobSide: "taker", update: "add_pct_factor", placement: "numerator", damageKind: "normal", effectBucket: true },Index: simulator/src/effectIndex.test.ts===================================================================--- simulator/src/effectIndex.test.ts prev run+++ simulator/src/effectIndex.test.ts this run@@ -1,8 +1,8 @@import assert from "node:assert/strict";import { test } from "node:test";-import { createEffectIndex, damageJobSlot, damageShapeSlotsForEffect, expireEffectIndex, indexEffect } from "./effectIndex";+import { createEffectIndex, damageJobSlot, damageShapeSlotsForEffect, DAMAGE_JOB_SHAPE_SLOTS, expireEffectIndex, indexEffect } from "./effectIndex";import { resolvedEffectScopeKey } from "./effects";import { unitMask } from "./types";import type { ActiveEffect, DamageJob } from "./types";@@ -90,9 +90,9 @@}function preparedIndex(effects: ActiveEffect[]): ReturnType<typeof createEffectIndex> {const groups: NonNullable<ActiveEffect["effectGroup"]>[] = [];- const byShape: NonNullable<ActiveEffect["effectGroup"]>[][] = Array.from({ length: 72 }, () => []);+ const byShape: NonNullable<ActiveEffect["effectGroup"]>[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => []);const byResolvedGroup = new Map<string, NonNullable<ActiveEffect["effectGroup"]>>();for (const effect of effects) {const slots = damageShapeSlotsForEffect(effect);if (slots.length === 0) continue;Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -37,9 +37,10 @@battleOrder: ActiveEffect[];carriers: ActiveEffect[];}-export const DAMAGE_JOB_SHAPE_SLOTS = 2 * 2 * 3 * 2 * 3;+const DAMAGE_KINDS: DamageKind[] = ["normal", "skill", "extra"];+export const DAMAGE_JOB_SHAPE_SLOTS = DAMAGE_KINDS.length * 2 * 3 * 2 * 3;export function createEffectIndex(effectGroups: ActiveEffectGroup[] = [],damageGroupsByJobShape: ActiveEffectGroup[][] = Array.from({ length: DAMAGE_JOB_SHAPE_SLOTS }, () => [])@@ -136,9 +137,9 @@function buildShapeSlots(effect: ActiveEffect, bucket: DynamicDamageBucket): Uint8Array {const definition = dynamicBucketDefinition(bucket)!;const slots: number[] = [];- const jobKinds: DamageKind[] = definition.damageKind ? [definition.damageKind] : ["normal", "skill"];+ const jobKinds = definition.damageKind ? [definition.damageKind] : DAMAGE_KINDS;for (const jobKind of jobKinds) {for (const appliesToUnit of unitsFromMask(effect.appliesTo.units)) {for (const appliesVsUnit of unitsFromMask(effect.appliesVs.units)) {slots.push(@@ -151,9 +152,9 @@}return Uint8Array.from(slots);}-function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }+function kindIndex(kind: DamageKind): number { return DAMAGE_KINDS.indexOf(kind); }function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {if (unit === "infantry") return 0;if (unit === "lancer") return 1;Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -128,9 +128,9 @@const staticAppliesVs = resolveUnitScope(units.applies_vs, oppositeSide(staticAppliesTo.side), "applies_vs", undefined, ownerSide);const effectKind = kindForIntent(intent);const duration = normalizeDuration(intent.duration, effectKind);if (effectKind === "extra_attack" && (!intent.trigger_damage_jobs || intent.trigger_damage_jobs.length === 0)) {- throw new Error(`extra_skill_attack effect ${intent.id} requires at least one trigger_damage_jobs entry`);+ throw new Error(`${intent.type} effect ${intent.id} requires at least one trigger_damage_jobs entry`);}const evolution = intent.value_evolution;const compiled: CompiledActivation = {source: {Index: simulator/src/extraAttacks.ts===================================================================--- simulator/src/extraAttacks.ts prev run+++ simulator/src/extraAttacks.ts this run@@ -22,77 +22,150 @@type RunLoopOptions,type Runtime} from "./runtime";-// Spawn, calculate, and charge the extra_skill_attack attacks riding on one normal attack.+// 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. Each applicable effect expands its trigger_damage_jobs into jobs that run immediately, in-// place; the effect is charged one use only when at least one of its own jobs actually ran.+// cluster. A triggered child can instead carry damage captured in an earlier round;+// that fixed result is emitted only when the child's locked normal attack matches.+// 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.-export function processExtraSkillAttacks(+export function processExtraAttacks(normalAttack: DamageJob,intent: AttackIntent,runtime: Runtime,fighters: Record<SideId, ResolvedFighter>,damageJobOptions: DamageJobOptions,roundTargetDamage: Record<SideId, Record<UnitType, number>>,loopOptions: RunLoopOptions,results: DamageJobResult[]-): number {- if (runtime.effectIndex.extraAttacks.length === 0) return 0;+): { totalKills: number; skillKills: number } {+ if (runtime.effectIndex.extraAttacks.length === 0) return { totalKills: 0, skillKills: 0 };const { round, roundStartTroops } = normalAttack;const recorder = damageJobOptions.recorder;- let triggeredKills = 0;+ 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));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 deliveryJob: DamageJob = {+ ...pending.job,+ round,+ calculationRound: pending.job.round+ };+ if (loopOptions.capRoundKills && targetExhausted(deliveryJob, roundStartTroops, roundTargetDamage)) continue;+ recorder.recordScheduledDamageJob(deliveryJob);+ if (loopOptions.capRoundKills) {+ capJobToRemainingTarget(pending.result, deliveryJob, roundStartTroops, roundTargetDamage, recorder);+ } else if (loopOptions.capJobKills) {+ pending.result.kills = Math.min(+ pending.result.kills,+ Math.max(0, roundStartTroops[deliveryJob.takerSide][deliveryJob.takerUnit] ?? 0)+ );+ recorder.recordFinalKills(pending.result);+ }+ results.push({ job: deliveryJob, result: pending.result, intent });+ totalKills += pending.result.kills;+ processedJobCount += 1;+ firstProcessedJob ??= deliveryJob;+ }+ } 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);+ const multiplier = effect.getCurrentValue(round) / 100;+ if (multiplier <= 0) continue;+ for (const source of sources) {+ if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;+ for (const target of targets) {+ if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;+ const job: DamageJob = {+ round,+ kind: definition.damage_kind ?? "skill",+ roundStartTroops,+ dealerSide: source.side,+ dealerUnit: source.unit,+ takerSide: target.side,+ takerUnit: target.unit,+ sourceEffectId,+ sourceMultiplier: multiplier+ };+ if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;+ recorder.recordScheduledDamageJob(job);+ const result = calculateDamageJob(job, fighters, damageJobOptions);+ if (job.kind === "skill") recorder.recordSkillDamageJob(job, effect);+ if (loopOptions.capRoundKills) {+ capJobToRemainingTarget(result, job, roundStartTroops, roundTargetDamage, recorder);+ }+ results.push({ job, result, intent });+ totalKills += result.kills;+ if (job.kind === "skill") {+ skillKills += result.kills;+ runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;+ }+ processedJobCount += 1;+ firstProcessedJob ??= job;+ chargeUsedEffectsForJob(runtime, job, recorder);+ }+ }+ }+ }+ if (processedJobCount > 0) {+ materializeTriggeredEffects(effect, round, effectUseIntent(firstProcessedJob!), runtime, recorder);+ chargeEffectUse(runtime, effect);+ recorder.recordExtraAttack(normalAttack, effect, processedJobCount);+ }+ }+ return { totalKills, skillKills };+}++export function captureTriggeredExtraDamage(+ effects: ActiveEffect[],+ normalAttack: DamageJob,+ fighters: Record<SideId, ResolvedFighter>,+ damageJobOptions: DamageJobOptions,+ runtime: Runtime+): void {+ const { round, roundStartTroops } = normalAttack;+ for (const effect of effects) {+ if (effect.expired || effect.intent.type !== "extra_skill_attack" || effect.pendingDamageJobs) continue;+ const multiplier = effect.getCurrentValue(round) / 100;+ if (multiplier <= 0) continue;+ const pending = [];for (const definition of effect.triggerDamageJobs ?? []) {const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);const targets = resolveTriggerJobSelector(definition.target, "target", effect, normalAttack, roundStartTroops);- const multiplier = effect.getCurrentValue(round) / 100;- if (multiplier <= 0) continue;for (const source of sources) {if ((roundStartTroops[source.side][source.unit] ?? 0) <= 0) continue;for (const target of targets) {if ((roundStartTroops[target.side][target.unit] ?? 0) <= 0) continue;const job: DamageJob = {round,- kind: "skill",+ kind: definition.damage_kind ?? "skill",roundStartTroops,dealerSide: source.side,dealerUnit: source.unit,takerSide: target.side,takerUnit: target.unit,- sourceEffectId,+ sourceEffectId: effect.source.effectId ?? effect.intent.id,sourceMultiplier: multiplier};- recorder.recordSkillDamageJob(job, effect);- if (loopOptions.capRoundKills && targetExhausted(job, roundStartTroops, roundTargetDamage)) continue;- recorder.recordScheduledDamageJob(job);- const result = calculateDamageJob(job, fighters, damageJobOptions);- if (loopOptions.capRoundKills) capJobToRemainingTarget(result, job, roundStartTroops, roundTargetDamage, recorder);- processedJobCount += 1;- firstProcessedJob ??= job;- runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;- results.push({ job, result, intent });- triggeredKills += result.kills;- chargeUsedEffectsForJob(runtime, job, recorder);+ const result = calculateDamageJob(job, fighters, { ...damageJobOptions, capToTakerTroops: false });+ pending.push({ job, result });+ chargeUsedEffectsForJob(runtime, job, damageJobOptions.recorder);}}}- if (processedJobCount > 0) {- materializeTriggeredEffects(effect, round, effectUseIntent(firstProcessedJob!), runtime, recorder);- chargeEffectUse(runtime, effect);- recorder.recordExtraAttack(normalAttack, effect, processedJobCount);- }+ if (pending.length > 0) effect.pendingDamageJobs = pending;}- return triggeredKills;}function extraAttackEffectAppliesToNormalAttack(effect: ActiveEffect, normalAttack: DamageJob): boolean {return (Index: simulator/src/recorder.ts===================================================================--- simulator/src/recorder.ts prev run+++ simulator/src/recorder.ts this run@@ -309,8 +309,9 @@this.extraAttackEvents.delete(job);const appliedEffects = mergeAppliedEffects(result.appliedEffects, order, extras);this.attacks.push({round: job.round,+ ...(job.calculationRound === undefined ? {} : { calculationRound: job.calculationRound }),kind: job.kind,sourceEffectId: job.sourceEffectId,dealerSide: job.dealerSide,dealerUnit: job.dealerUnit,Index: simulator/src/runtime.ts===================================================================--- simulator/src/runtime.ts prev run+++ simulator/src/runtime.ts this run@@ -272,17 +272,20 @@round: number,useIntent: AttackIntent,runtime: Runtime,recorder: BattleRecorder-): void {+): ActiveEffect[] {const skill = parent.sourceSkill;- if (!skill || !parent.triggerEffects?.length) return;+ if (!skill || !parent.triggerEffects?.length) return [];+ const children: ActiveEffect[] = [];for (const childIntent of parent.triggerEffects) {const child = activateEffect(skill, childIntent, round, useIntent);addActiveEffect(runtime, child);+ children.push(child);runtime.effectActivationCounts[skill.side] += 1;recorder.recordSkillEffectActivated(skill);}+ return children;}/** Materialize selected parents' children after the job, then charge all participating effects. */export function chargeUsedEffectsForJob(Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -362,8 +362,27 @@assert.ok(defensive.attacks.some((attack) => attack.kills > 5000));assert.ok(damage.score > defensive.score);});+test("simulateBearBattle includes carried extra damage in score without treating it as skill kills", () => {+ const result = simulateBearBattle(+ {+ troops: { lancer_t1: 1000 },+ stats: {},+ heroes: { Renee: { skill_1: 1 } }+ },+ loadSimulatorConfig(),+ "bear-renee-extra"+ );++ const attackerDamage = result.attacks+ .filter((attack) => attack.dealerSide === "attacker")+ .reduce((total, attack) => total + attack.kills, 0);+ assert.equal(result.score, attackerDamage);+ assert.equal(result.attacks.some((attack) => attack.kind === "extra"), true);+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "NightmareTrace")?.skillKills, 0);+});+test("runPrepared calculates all same-round damage from the round-start troop snapshot", () => {const config = loadSimulatorConfig();const result = runOnce({@@ -727,8 +746,167 @@assert.equal(round4LancerAttack?.takerUnit, "marksman");assert.equal((round4LancerAttack?.appliedEffects ?? []).some((effect) => effect.effectId === "mark"), false);});+test("carried extra damage expires when the next normal attack does not match its locked target", () => {+ const result = runOnce(+ {+ maxRounds: 3,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }+ },+ defender: {+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },+ heroes: {}+ }+ },+ minimalConfig({+ DreamLancer: {+ name: "DreamLancer",+ troop_type: "lancer",+ skills: {+ EvenTurnAmbusher: {+ trigger: { type: "turn", every: 2 },+ effects: {+ order: {+ type: "attack_order",+ value: ["marksman", "infantry", "lancer"],+ units: { applies_to: "lancer", applies_vs: "marksman" },+ duration: { turns: { count: 1 } }+ }+ }+ },+ NightmareTrace: {+ trigger: { type: "turn", every: 2 },+ effects: {+ mark: {+ units: { applies_to: "self.lancer", applies_vs: "enemy.any" },+ duration: { turns: { count: 1 }, attacks: { count: 1 } },+ trigger_effects: {+ delayed: {+ type: "extra_skill_attack",+ value: 40,+ units: { applies_to: "parent.use.source", applies_vs: "parent.use.target" },+ trigger_damage_jobs: [{ source: "use.source", target: "use.target", damage_kind: "extra" }],+ duration: { turns: { delay: 1, count: 1 }, attacks: { count: 1 } }+ }+ }+ }+ }+ }+ }+ }+ }),+ { mode: "trace" }+ );++ const lancerAttacks = result.attacks.filter((attack) => attack.dealerSide === "attacker" && attack.dealerUnit === "lancer");+ assert.equal(lancerAttacks.find((attack) => attack.round === 2 && attack.kind === "normal")?.takerUnit, "marksman");+ assert.equal(lancerAttacks.find((attack) => attack.round === 3 && attack.kind === "normal")?.takerUnit, "infantry");+ assert.equal(lancerAttacks.some((attack) => attack.sourceEffectId === "delayed"), false);+});++test("Renee snapshots Nightmare Trace as extra damage on the even turn and only delivers it next turn", () => {+ const result = runOnce(+ {+ maxRounds: 3,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: { Renee: { skill_1: 1, skill_2: 1, skill_3: 1 } }+ },+ defender: {+ troops: { marksman_t1: 1000000 },+ heroes: {}+ }+ },+ loadSimulatorConfig(),+ { mode: "trace" }+ );++ const attackerJobs = result.attacks.filter((attack) => attack.dealerSide === "attacker");+ const roundTwoNormal = attackerJobs.find((attack) => attack.round === 2 && attack.kind === "normal");+ const delayed = attackerJobs.find((attack) => attack.sourceEffectId === "NightmareTrace/1");+ const roundThreeNormal = attackerJobs.find((attack) => attack.round === 3 && attack.kind === "normal");++ assert.equal(attackerJobs.some((attack) => attack.round === 2 && attack.kind === "skill"), false);+ assert.equal(delayed?.kind, "extra");+ assert.equal(delayed?.round, 3);+ assert.equal(delayed?.calculationRound, 2);+ assert.ok(Math.abs(delayed!.kills - roundTwoNormal!.kills * 0.4) < 1e-9);+ assert.equal(delayed?.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);+ assert.equal(delayed?.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 0);+ assert.equal(roundThreeNormal?.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);+ assert.equal(roundThreeNormal?.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 45);++ const nightmareTrace = result.skillReport.attacker.find((entry) => entry.skillId === "NightmareTrace");+ assert.equal(nightmareTrace?.skillKills, 0);+});++test("Wu Ming skill-damage amplification does not affect Renee's carried extra damage", () => {+ const result = runOnce(+ {+ maxRounds: 3,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: {+ WuMing: { skill_2: 1, skill_3: 1 },+ Renee: { skill_1: 1 }+ }+ },+ defender: {+ troops: { marksman_t1: 1000000 },+ heroes: {}+ }+ },+ loadSimulatorConfig(),+ { mode: "trace" }+ );++ const delayed = result.attacks.find((attack) => attack.sourceEffectId === "NightmareTrace/1");+ assert.equal(delayed?.kind, "extra");+ assert.equal(delayed?.trace?.atomicBuckets["active.hero.damage.up"].totalPct, 4);+ assert.equal(delayed?.trace?.aggregationGroups["type.skill.damage.up"], undefined);+ assert.equal(result.skillReport.attacker.find((entry) => entry.skillId === "NightmareTrace")?.skillKills, 0);+});++test("Renee's marked-target bucket multiplies separately from the single-target counterfactual", () => {+ const base = loadSimulatorConfig();+ const heroDefinitions = structuredClone(base.heroDefinitions);+ const reneeSkills = heroDefinitions.Renee.skills;+ for (const [skillId, effectId] of [["Dreamcatcher", "Dreamcatcher/1"], ["Dreamslice", "Dreamslice/1"]] as const) {+ reneeSkills[skillId].effects[`${skillId}/mark`].trigger_effects![effectId].type = "type.single_target.damage.up";+ }+ const singleTargetConfig = buildSimulatorConfig({+ heroGenerationStats: base.heroGenerationStats,+ troopSkills: base.troopSkills,+ heroDefinitions+ });+ const input: BattleInput = {+ maxRounds: 3,+ attacker: {+ troops: { lancer_t1: 1000000 },+ heroes: { Renee: { skill_1: 1, skill_2: 1, skill_3: 1 } }+ },+ defender: {+ troops: { marksman_t1: 1000000 },+ heroes: {}+ }+ };++ const shared = runOnce(input, singleTargetConfig, { mode: "trace" });+ const separate = runOnce(input, base, { mode: "trace" });+ const sharedNormal = shared.attacks.find((attack) => attack.round === 3 && attack.kind === "normal" && attack.dealerSide === "attacker")!;+ const separateNormal = separate.attacks.find((attack) => attack.round === 3 && attack.kind === "normal" && attack.dealerSide === "attacker")!;+ const separateDelayed = separate.attacks.find((attack) => attack.sourceEffectId === "NightmareTrace/1")!;++ assert.equal(sharedNormal.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 55);+ assert.equal(separateNormal.trace?.atomicBuckets["type.single_target.damage.up"].totalPct, 10);+ assert.equal(separateNormal.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 45);+ assert.ok(Math.abs(separateNormal.kills / sharedNormal.kills - (1.1 * 1.45) / 1.55) < 1e-12);+ assert.equal(separateDelayed.trace?.atomicBuckets["type.marked_target.damage.up"].totalPct, 0);+});+test("attack-duration effects with a round cap expire after one applicable attack", () => {const result = runOnce({maxRounds: 2,Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -47,9 +47,9 @@type DamageJobResult,type RunLoopOptions,type Runtime} from "./runtime";-import { processExtraSkillAttacks } from "./extraAttacks";+import { captureTriggeredExtraDamage, processExtraAttacks } from "./extraAttacks";// Re-exported so the public battle API stays importable from one module.export { prepareBattle, type CompiledBattle } from "./prepare";import { emptyTroops, resolveFighter } from "./fighterResolution";@@ -256,9 +256,8 @@const intents: AttackIntent[] = [];const results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];const roundTargetDamage = emptyRoundTargetDamage();-// Resolve each normal attack as one procedural cluster. Later attacks observe effects// produced by earlier attacks; no synthetic global attack-declaration phase exists.const orderIndexBySide: Record<SideId, number> = { attacker: 0, defender: 0 };for (const dealerUnit of UNIT_TYPES) {@@ -306,9 +305,9 @@const deferredEffects = matchingTriggerSkills? triggerAttackSkills(round, matchingTriggerSkills, runtime, recorder, intent): undefined;const dodge = applicableControl(job, runtime, "dodge");- fireApplicableCarriers(round, job, intent, runtime, recorder);+ const triggeredChildren = fireApplicableCarriers(round, job, intent, runtime, recorder);let normalKills = 0;if (dodge) {runtime.attackControlCounts.dodge += 1;@@ -325,20 +324,21 @@chargeUsedEffectsForJob(runtime, job, recorder);results.push({ job, result: normalResult, intent });}+ captureTriggeredExtraDamage(triggeredChildren, job, fighters, damageJobOptions, runtime);advanceNormalAttackCounters(intent, runtime);- const triggeredKills = processExtraSkillAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);+ const extraAttacks = processExtraAttacks(job, intent, runtime, fighters, damageJobOptions, roundTargetDamage, loopOptions, results);if (loopOptions.scoreSide && job.dealerSide === loopOptions.scoreSide.dealerSide && job.takerSide === loopOptions.scoreSide.takerSide) {- score += triggeredKills;+ score += extraAttacks.totalKills;}if (deferredEffects) {materializeDeferredEffects(deferredEffects,round,intent,normalKills,- triggeredKills,+ extraAttacks.skillKills,sourceAttackProtectionBasis(job, fighters, initialArmyCount),runtime,recorder);@@ -507,11 +507,12 @@job: DamageJob,intent: AttackIntent,runtime: Runtime,recorder: BattleRecorder-): void {+): ActiveEffect[] {const carriers = runtime.effectIndex.carriers;const originalCount = carriers.length;+ const children: ActiveEffect[] = [];let index = 0;for (let inspected = 0; inspected < originalCount; inspected += 1) {const carrier = carriers[index];if (!carrier) break;@@ -520,15 +521,16 @@carrier.appliesVs.side === job.takerSide &&unitMaskHas(carrier.appliesVs.units, job.takerUnit) &&advanceEffectAttackDelay(carrier);if (matches) {- materializeTriggeredEffects(carrier, round, intent, runtime, recorder);+ children.push(...materializeTriggeredEffects(carrier, round, intent, runtime, recorder));chargeEffectUse(runtime, carrier);}// chargeEffectUse may have removed the current carrier. Keep the same index// in that case so the shifted original entry is inspected next.if (carriers[index] === carrier) index += 1;}+ return children;}function advanceNormalAttackCounters(intent: AttackIntent, runtime: Runtime): void {runtime.counters.attacks[intent.dealerSide][intent.dealerUnit] += 1;Index: simulator/src/tooling/parityMetrics.ts===================================================================--- simulator/src/tooling/parityMetrics.ts prev run+++ simulator/src/tooling/parityMetrics.ts this run@@ -51,9 +51,9 @@if (options.deterministic) {statType = "deterministic";passes = Math.abs(biasPct) <= deterministicLimit;- } else if (options.candidate.sigma === 0) {+ } else if (options.candidate.sigma === 0 && options.reference.sigma === 0) {statType = "zero_var";passes = Math.abs(biasPct) <= deterministicLimit;} else if (options.reference.n <= 1) {statType = "single_obs";@@ -62,12 +62,23 @@p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;} else {statType = "t";- sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);- stat = sem === 0 ? null : round(biasRaw / sem, 4);- p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);- passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;+ const candidateVariance = (options.candidate.sigma ** 2) / Math.max(options.candidate.n, 1);+ const referenceVariance = (options.reference.sigma ** 2) / options.reference.n;+ sem = Math.sqrt(candidateVariance + referenceVariance);+ const rawStat = sem === 0 ? null : biasRaw / sem;+ const degreesOfFreedom = welchDegreesOfFreedom(+ candidateVariance,+ options.candidate.n,+ referenceVariance,+ options.reference.n+ );+ const rawP = rawStat === null ? null : studentTwoSidedP(rawStat, degreesOfFreedom);+ stat = rawStat === null ? null : round(rawStat, 4);+ p = rawP === null ? null : round(rawP, 6);+ const alpha = 2 * (1 - normalCdf(zThreshold));+ passes = rawP === null || rawP >= alpha || Math.abs(biasPct) <= minBiasPct;}return {n_candidate: options.candidate.n,@@ -103,8 +114,88 @@function normalCdf(x: number): number {return 0.5 * (1 + erf(x / Math.SQRT2));}+function welchDegreesOfFreedom(candidateVariance: number, candidateN: number, referenceVariance: number, referenceN: number): number {+ const numerator = (candidateVariance + referenceVariance) ** 2;+ const denominator = varianceDegreesTerm(candidateVariance, candidateN) + varianceDegreesTerm(referenceVariance, referenceN);+ return denominator === 0 ? Number.POSITIVE_INFINITY : numerator / denominator;+}++function varianceDegreesTerm(variance: number, n: number): number {+ return n <= 1 ? 0 : (variance ** 2) / (n - 1);+}++function studentTwoSidedP(stat: number, degreesOfFreedom: number): number {+ if (!Number.isFinite(degreesOfFreedom)) return 2 * (1 - normalCdf(Math.abs(stat)));+ const x = degreesOfFreedom / (degreesOfFreedom + stat ** 2);+ return regularizedIncompleteBeta(x, degreesOfFreedom / 2, 0.5);+}++function regularizedIncompleteBeta(x: number, a: number, b: number): number {+ if (x <= 0) return 0;+ if (x >= 1) return 1;+ const scale = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x));+ if (x < (a + 1) / (a + b + 2)) return (scale * betaContinuedFraction(x, a, b)) / a;+ return 1 - (scale * betaContinuedFraction(1 - x, b, a)) / b;+}++function betaContinuedFraction(x: number, a: number, b: number): number {+ const maxIterations = 200;+ const epsilon = 3e-14;+ const minimum = 1e-300;+ const qab = a + b;+ const qap = a + 1;+ const qam = a - 1;+ let c = 1;+ let d = 1 - (qab * x) / qap;+ if (Math.abs(d) < minimum) d = minimum;+ d = 1 / d;+ let result = d;++ for (let iteration = 1; iteration <= maxIterations; iteration += 1) {+ const even = iteration * 2;+ let coefficient = (iteration * (b - iteration) * x) / ((qam + even) * (a + even));+ d = 1 + coefficient * d;+ if (Math.abs(d) < minimum) d = minimum;+ c = 1 + coefficient / c;+ if (Math.abs(c) < minimum) c = minimum;+ d = 1 / d;+ result *= d * c;++ coefficient = -((a + iteration) * (qab + iteration) * x) / ((a + even) * (qap + even));+ d = 1 + coefficient * d;+ if (Math.abs(d) < minimum) d = minimum;+ c = 1 + coefficient / c;+ if (Math.abs(c) < minimum) c = minimum;+ d = 1 / d;+ const delta = d * c;+ result *= delta;+ if (Math.abs(delta - 1) < epsilon) break;+ }++ return result;+}++function logGamma(value: number): number {+ const coefficients = [+ 676.5203681218851,+ -1259.1392167224028,+ 771.3234287776531,+ -176.6150291621406,+ 12.507343278686905,+ -0.13857109526572012,+ 9.984369578019572e-6,+ 1.5056327351493116e-7+ ];+ if (value < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * value)) - logGamma(1 - value);+ let series = 0.9999999999998099;+ const shifted = value - 1;+ for (let index = 0; index < coefficients.length; index += 1) series += coefficients[index] / (shifted + index + 1);+ const t = shifted + coefficients.length - 0.5;+ return 0.5 * Math.log(2 * Math.PI) + (shifted + 0.5) * Math.log(t) - t + Math.log(series);+}+function erf(x: number): number {const sign = x < 0 ? -1 : 1;const ax = Math.abs(x);const t = 1 / (1 + 0.3275911 * ax);Index: simulator/src/tooling/testcases.test.ts===================================================================--- simulator/src/tooling/testcases.test.ts prev run+++ simulator/src/tooling/testcases.test.ts this run@@ -403,8 +403,24 @@assert.equal(metrics.sem, 3.79);assert.equal(metrics.stat, -4.4459);});+test("compareOutcomeDistribution includes noisy game samples in a Welch t comparison", () => {+ const metrics = compareOutcomeDistribution({+ candidate: { n: 100, mu: -1149.6, sigma: 1578.2 },+ reference: { n: 10, mu: 179.3, sigma: 2006.6 },+ initialTroops: 17_500,+ deterministic: false,+ thresholds: { z_threshold: 2, min_bias_pct: 0.5 }+ });++ assert.equal(metrics.stat_type, "t");+ assert.equal(metrics.sem, 653.87);+ assert.equal(metrics.stat, -2.0323);+ assert.equal(metrics.p, 0.069131);+ assert.equal(metrics.passes, true);+});+test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {const rows = [{ p: 0.01, q: null },{ p: 0.03, q: null },Index: simulator/src/tooling/testcases.ts===================================================================--- simulator/src/tooling/testcases.ts prev run+++ simulator/src/tooling/testcases.ts this run@@ -10,9 +10,9 @@} from "./calibration";import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";import { prepareBattle, runPrepared } from "../simulator";import { DamageAggregationError } from "../staticDamageProfile";-import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "../types";+import type { BattleInput, BattleResult, FighterInput, SimulationMode, SimulatorConfig, StatBlock, UnitType } from "../types";const DEFAULT_STOCHASTIC_REPEAT = 100;const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;const STAT_ROUNDING_SCAN_STEPS = 50;@@ -145,8 +145,10 @@index: number;input: BattleInput;repeat: number;seed?: string | number;+ includeSamples?: boolean;+ simulationMode?: SimulationMode;}export interface TestcaseExecutionResult {testcaseId: string;@@ -618,9 +620,13 @@const sampleDeltas: number[] = [];// Resolve the battle once and reuse it across every seeded sample of this case.const compiled = prepareBattle(job.input, config);const baseSeed = job.seed ?? job.input.seed;- const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));+ const sample = (iteration: number) => runPrepared(+ compiled,+ sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration),+ job.simulationMode ? { mode: job.simulationMode } : {}+ );let result = sample(0);const firstScore = battleScoreDelta(result);if (firstScore !== undefined) {samples.push(firstScore);@@ -644,9 +650,9 @@index: job.index,result,deterministic: result.randomness.deterministic,sampleCount,- simulatorStats: sampleStats(samples),+ simulatorStats: sampleStats(samples, { includeSamples: job.includeSamples }),simulatorScoreDelta: battleScoreDelta(result),simulatorSampleOutcomes: sampleOutcomes,simulatorSampleDeltas: sampleDeltas,diagnostics: [...result.resolved.attacker.diagnostics, ...result.resolved.defender.diagnostics]Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -1,7 +1,7 @@export type SideId = "attacker" | "defender";export type UnitType = "infantry" | "lancer" | "marksman";-export type DamageKind = "normal" | "skill";+export type DamageKind = "normal" | "skill" | "extra";export type UnitMask = number;export type ActiveEffectKind = "modifier" | "shield" | "extra_attack" | "control" | "battle_order" | "carrier";export type SameEffectStacking = "add" | "max";@@ -31,8 +31,10 @@export interface TriggerDamageJobDefinition {source: TriggerDamageJobSelector;target: TriggerDamageJobSelector;+ /** Damage classification used when the job is calculated. Defaults to skill. */+ damage_kind?: DamageKind;}export function unitMask(units: UnitType | UnitType[]): UnitMask {const list = Array.isArray(units) ? units : [units];@@ -321,8 +323,10 @@// unless useEffectsOnDodge/useEffectsOnNoAttack disable that.uses: number;sameEffectStacking: SameEffectStacking;triggerEffects?: ResolvedEffectIntentDefinition[];+ /** Completed damage captured when this runtime effect was materialized and emitted on its later matching attack. */+ pendingDamageJobs?: PendingDamageJob[];effectGroup?: ActiveEffectGroup;effectGroupPosition?: number;}@@ -349,8 +353,10 @@}export interface DamageJob {round: number;+ /** Present when a delayed job was calculated in an earlier round. */+ calculationRound?: number;kind: DamageKind;roundStartTroops: Record<SideId, Record<UnitType, number>>;dealerSide: SideId;dealerUnit: UnitType;@@ -359,8 +365,19 @@sourceEffectId?: string;sourceMultiplier?: number;}+export interface DamageResult {+ kills: number;+ appliedEffects?: AppliedEffect[];+ trace?: DamageEquationTrace;+}++export interface PendingDamageJob {+ job: DamageJob;+ result: DamageResult;+}+export interface CounterDelta {side: SideId;unit: UnitType;counter: "attacks" | "received_attacks";@@ -455,8 +472,10 @@export type AppliedEffect = AppliedPercentageEffectSummary | AppliedShieldEffectSummary | DetailedAppliedEffect;export interface AttackOutcome {round: number;+ /** Present when this damage was snapshotted in an earlier round. */+ calculationRound?: number;kind: DamageKind;sourceEffectId?: string;dealerSide: SideId;dealerUnit: UnitType;Index: testcases/1-testcases_no-heroes_t6_single-type_nc.json===================================================================--- testcases/1-testcases_no-heroes_t6_single-type_nc.json prev run+++ testcases/1-testcases_no-heroes_t6_single-type_nc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_9",+ "test_id": "no_heroes_single_t6__9","time": "2025-07-13 - 12:02","attacker": {"name": "daut","stats": {@@ -67,9 +67,9 @@"defender": 0}},{- "test_id": "daut_viper_10",+ "test_id": "no_heroes_single_t6__10","time": "2025-07-13 - 12:07","attacker": {"name": "daut","stats": {@@ -135,9 +135,9 @@"defender": 1618}},{- "test_id": "daut_viper_11",+ "test_id": "no_heroes_single_t6__11","time": "2025-07-13 - 12:08","attacker": {"name": "daut","stats": {@@ -203,9 +203,9 @@"defender": 376}},{- "test_id": "daut_viper_12",+ "test_id": "no_heroes_single_t6__12","time": "2025-07-13 - 12:15","attacker": {"name": "daut","stats": {@@ -271,9 +271,9 @@"defender": 6}},{- "test_id": "daut_viper_13",+ "test_id": "no_heroes_single_t6__13","time": "2025-07-13 - 12:21","attacker": {"name": "daut","stats": {@@ -339,9 +339,9 @@"defender": 0}},{- "test_id": "daut_viper_14",+ "test_id": "no_heroes_single_t6__14","time": "2025-07-13 - 12:24","attacker": {"name": "daut","stats": {@@ -407,9 +407,9 @@"defender": 0}},{- "test_id": "daut_viper_15",+ "test_id": "no_heroes_single_t6__15","time": "2025-07-13 - 12:27","attacker": {"name": "daut","stats": {@@ -475,9 +475,9 @@"defender": 254}},{- "test_id": "daut_viper_16",+ "test_id": "no_heroes_single_t6__16","time": "2025-07-13 - 12:29","attacker": {"name": "daut","stats": {@@ -543,9 +543,9 @@"defender": 0}},{- "test_id": "daut_viper_17",+ "test_id": "no_heroes_single_t6__17","time": "2025-07-13 - 12:32","attacker": {"name": "daut","stats": {@@ -613,9 +613,9 @@"defender": 0}},{- "test_id": "daut_viper_18",+ "test_id": "no_heroes_single_t6__18","time": "2025-07-13 - 12:34","attacker": {"name": "daut","stats": {@@ -683,9 +683,9 @@"defender": 177}},{- "test_id": "daut_viper_19",+ "test_id": "no_heroes_single_t6__19","time": "2025-07-13 - 12:36","attacker": {"name": "daut","stats": {@@ -753,9 +753,9 @@"defender": 489}},{- "test_id": "daut_viper_20",+ "test_id": "no_heroes_single_t6__20","time": "2025-07-13 - 12:39","attacker": {"name": "daut","stats": {@@ -823,9 +823,9 @@"defender": 0}},{- "test_id": "daut_viper_21",+ "test_id": "no_heroes_single_t6__21","time": "2025-07-13 - 12:42","attacker": {"name": "daut","stats": {@@ -893,9 +893,9 @@"defender": 193}},{- "test_id": "daut_viper_22",+ "test_id": "no_heroes_single_t6__22","time": "2025-07-13 - 12:43","attacker": {"name": "daut","stats": {@@ -963,9 +963,9 @@"defender": 0}},{- "test_id": "daut_viper_23",+ "test_id": "no_heroes_single_t6__23","time": "2025-07-13 - 12:45","attacker": {"name": "daut","stats": {@@ -1033,9 +1033,9 @@"defender": 277}},{- "test_id": "daut_viper_24",+ "test_id": "no_heroes_single_t6__24","time": "2025-07-13 - 12:47","attacker": {"name": "daut","stats": {@@ -1103,9 +1103,9 @@"defender": 0}},{- "test_id": "daut_viper_25",+ "test_id": "no_heroes_single_t6__25","time": "2025-07-13 - 12:50","attacker": {"name": "daut","stats": {@@ -1173,9 +1173,9 @@"defender": 45}},{- "test_id": "daut_viper_26",+ "test_id": "no_heroes_single_t6__26","time": "2025-07-13 - 12:54","attacker": {"name": "daut","stats": {Index: testcases/2-testcases_no-heroes_t6_mixed_nc.json===================================================================--- testcases/2-testcases_no-heroes_t6_mixed_nc.json prev run+++ testcases/2-testcases_no-heroes_t6_mixed_nc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "no_heroes_t6_mixed_1","time": "2025-07-12 - 22:59","attacker": {"name": "daut","stats": {@@ -77,9 +77,9 @@"defender": 0}},{- "test_id": "daut_viper_2",+ "test_id": "no_heroes_t6_mixed_2","time": "2025-07-18 - 02:17","attacker": {"name": "daut","heroes": {},@@ -155,9 +155,9 @@"defender": 61}},{- "test_id": "daut_viper_3",+ "test_id": "no_heroes_t6_mixed_3","time": "2025-07-18 - 02:30","attacker": {"name": "daut","heroes": {},@@ -233,9 +233,9 @@"defender": 0}},{- "test_id": "daut_viper_4",+ "test_id": "no_heroes_t6_mixed_4","time": "2025-07-18 - 03:17","attacker": {"name": "daut","heroes": {},@@ -311,9 +311,9 @@"defender": 0}},{- "test_id": "daut_viper_5",+ "test_id": "no_heroes_t6_mixed_5","time": "2025-07-18 - 03:24","attacker": {"name": "daut","heroes": {},@@ -389,9 +389,9 @@"defender": 0}},{- "test_id": "daut_viper_6",+ "test_id": "no_heroes_t6_mixed_6","time": "2025-07-18 - 03:33","attacker": {"name": "daut","heroes": {},Index: testcases/3-testcases_mixed-heroes-not-verified.json===================================================================--- testcases/3-testcases_mixed-heroes-not-verified.json prev run+++ testcases/3-testcases_mixed-heroes-not-verified.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "no_heroes_t6_mixed_2x_1","time": "2025-07-12 - 23:09","attacker": {"name": "daut2x","stats": {@@ -84,9 +84,9 @@"defender": 0}},{- "test_id": "daut_viper_2",+ "test_id": "no_heroes_t6_mixed_2x_2","time": "2025-07-12 - 23:17","attacker": {"name": "daut2x","stats": {@@ -166,9 +166,9 @@"defender": 3729}},{- "test_id": "daut_viper_3",+ "test_id": "no_heroes_t6_mixed_2x_3","time": "2025-07-13 - 13:45","attacker": {"name": "daut2x","stats": {@@ -280,9 +280,9 @@"defender": 2823}},{- "test_id": "daut_viper_4",+ "test_id": "no_heroes_t6_mixed_2x_4","time": "2025-07-13 - 16:51","attacker": {"name": "daut2x","stats": {@@ -392,9 +392,9 @@"defender": 519}},{- "test_id": "daut_viper_5",+ "test_id": "no_heroes_t6_mixed_2x_5","time": "2025-07-13 - 16:57","attacker": {"name": "daut2x","heroes": {@@ -504,9 +504,9 @@"defender": 0}},{- "test_id": "daut_viper_7",+ "test_id": "no_heroes_t6_mixed_2x_6","time": "2025-07-13 - 17:03","attacker": {"name": "daut2x","heroes": {Index: testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.json===================================================================--- testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.json prev run+++ testcases/emulator_verified/ambusher_logan_renee_vs_logan_patrick_nc.json this run@@ -0,0 +1,429 @@+[+ {+ "test_id": "ambusher_logan_renee_vs_logan_patrick_nc",+ "description": "Ambusher capture over 10 battles. MiniClaw2 attacks with Logan 1/0/0 and Renee 1/0/0 using 6000 T9 Infantry and 1500 T9 Lancers. [LAB]MiniClaw defends with Logan 1/1/0 and Patrick 1/1 using 5000 T6 Infantry and 5000 T6 Marksmen. Stats are the displayed battle-report bonuses; per-trial skill counts and troop outcomes are retained in metadata.",+ "attacker": {+ "name": "MiniClaw2",+ "heroes": {+ "Logan": {+ "skill_1": 1,+ "skill_2": 0,+ "skill_3": 0+ },+ "Renee": {+ "skill_1": 1,+ "skill_2": 0,+ "skill_3": 0+ }+ },+ "troops": {+ "infantry_t9": 6000,+ "lancer_t9": 1500+ },+ "stats": {+ "inf": {+ "attack": 78.9,+ "defense": 80.1,+ "lethality": 36.0,+ "health": 36.0+ },+ "lanc": {+ "attack": 110.5,+ "defense": 111.7,+ "lethality": 54.0,+ "health": 54.0+ },+ "mark": {+ "attack": 42.3,+ "defense": 43.5,+ "lethality": 36.0,+ "health": 36.0+ }+ },+ "joiner_heroes": {}+ },+ "defender": {+ "name": "[LAB]MiniClaw",+ "heroes": {+ "Logan": {+ "skill_1": 1,+ "skill_2": 1,+ "skill_3": 0+ },+ "Patrick": {+ "skill_1": 1,+ "skill_2": 1+ }+ },+ "troops": {+ "infantry_t6": 5000,+ "marksman_t6": 5000+ },+ "stats": {+ "inf": {+ "attack": 141.3,+ "defense": 145.3,+ "lethality": 72.2,+ "health": 72.2+ },+ "lanc": {+ "attack": 148.0,+ "defense": 152.0,+ "lethality": 72.2,+ "health": 72.2+ },+ "mark": {+ "attack": 81.5,+ "defense": 85.5,+ "lethality": 72.2,+ "health": 72.2+ }+ },+ "joiner_heroes": {}+ },+ "game_report_result": [+ {+ "attacker": 2057,+ "defender": 0+ },+ {+ "attacker": 581,+ "defender": 9+ },+ {+ "attacker": 0,+ "defender": 2668+ },+ {+ "attacker": 1547,+ "defender": 0+ },+ {+ "attacker": 2428,+ "defender": 0+ },+ {+ "attacker": 2199,+ "defender": 0+ },+ {+ "attacker": 0,+ "defender": 1731+ },+ {+ "attacker": 1178,+ "defender": 0+ },+ {+ "attacker": 0,+ "defender": 1580+ },+ {+ "attacker": 0,+ "defender": 2209+ }+ ],+ "metadata": {+ "reported_initial_totals": {+ "attacker": 7500,+ "defender": 10000+ },+ "observed_battle_outcomes": [+ {+ "trial": 1,+ "skill_counts": {+ "renee_s1": 53,+ "ambusher": 23+ },+ "attacker": {+ "survivors": 2057,+ "infantry": {+ "kills": 491,+ "survivors": 557+ },+ "lancer": {+ "kills": 3009,+ "survivors": 1500+ }+ },+ "defender": {+ "survivors": 0,+ "infantry": {+ "kills": 523,+ "survivors": 0+ },+ "marksman": {+ "kills": 1383,+ "survivors": 0+ }+ }+ },+ {+ "trial": 2,+ "skill_counts": {+ "renee_s1": 58,+ "ambusher": 21+ },+ "attacker": {+ "survivors": 581,+ "infantry": {+ "kills": 421,+ "survivors": 9+ },+ "lancer": {+ "kills": 3079,+ "survivors": 581+ }+ },+ "defender": {+ "survivors": 9,+ "infantry": {+ "kills": 600,+ "survivors": 0+ },+ "marksman": {+ "kills": 1822,+ "survivors": 0+ }+ }+ },+ {+ "trial": 3,+ "skill_counts": {+ "renee_s1": 45,+ "ambusher": 13+ },+ "attacker": {+ "survivors": 0,+ "infantry": {+ "kills": 381,+ "survivors": 0+ },+ "lancer": {+ "kills": 2187,+ "survivors": 0+ }+ },+ "defender": {+ "survivors": 2668,+ "infantry": {+ "kills": 531,+ "survivors": 714+ },+ "marksman": {+ "kills": 2094,+ "survivors": 1954+ }+ }+ },+ {+ "trial": 4,+ "skill_counts": {+ "renee_s1": 53,+ "ambusher": 22+ },+ "attacker": {+ "survivors": 1547,+ "infantry": {+ "kills": 453,+ "survivors": 47+ },+ "lancer": {+ "kills": 3047,+ "survivors": 1500+ }+ },+ "defender": {+ "survivors": 0,+ "infantry": {+ "kills": 524,+ "survivors": 0+ },+ "marksman": {+ "kills": 1560,+ "survivors": 0+ }+ }+ },+ {+ "trial": 5,+ "skill_counts": {+ "renee_s1": 52,+ "ambusher": 23+ },+ "attacker": {+ "survivors": 2428,+ "infantry": {+ "kills": 498,+ "survivors": 928+ },+ "lancer": {+ "kills": 3092,+ "survivors": 1500+ }+ },+ "defender": {+ "survivors": 0,+ "infantry": {+ "kills": 525,+ "survivors": 0+ },+ "marksman": {+ "kills": 1251,+ "survivors": 0+ }+ }+ },+ {+ "trial": 6,+ "skill_counts": {+ "renee_s1": 53,+ "ambusher": 23+ },+ "attacker": {+ "survivors": 2199,+ "infantry": {+ "kills": 497,+ "survivors": 699+ },+ "lancer": {+ "kills": 3003,+ "survivors": 1500+ }+ },+ "defender": {+ "survivors": 0,+ "infantry": {+ "kills": 528,+ "survivors": 0+ },+ "marksman": {+ "kills": 1328,+ "survivors": 0+ }+ }+ },+ {+ "trial": 7,+ "skill_counts": {+ "renee_s1": 47,+ "ambusher": 16+ },+ "attacker": {+ "survivors": 0,+ "infantry": {+ "kills": 384,+ "survivors": 0+ },+ "lancer": {+ "kills": 2511,+ "survivors": 0+ }+ },+ "defender": {+ "survivors": 1731,+ "infantry": {+ "kills": 566,+ "survivors": 805+ },+ "marksman": {+ "kills": 2059,+ "survivors": 926+ }+ }+ },+ {+ "trial": 8,+ "skill_counts": {+ "renee_s1": 55,+ "ambusher": 23+ },+ "attacker": {+ "survivors": 1178,+ "infantry": {+ "kills": 424,+ "survivors": 0+ },+ "lancer": {+ "kills": 3076,+ "survivors": 1178+ }+ },+ "defender": {+ "survivors": 0,+ "infantry": {+ "kills": 567,+ "survivors": 0+ },+ "marksman": {+ "kills": 1646,+ "survivors": 0+ }+ }+ },+ {+ "trial": 9,+ "skill_counts": {+ "renee_s1": 51,+ "ambusher": 17+ },+ "attacker": {+ "survivors": 0,+ "infantry": {+ "kills": 415,+ "survivors": 0+ },+ "lancer": {+ "kills": 2533,+ "survivors": 0+ }+ },+ "defender": {+ "survivors": 1580,+ "infantry": {+ "kills": 557,+ "survivors": 329+ },+ "marksman": {+ "kills": 2068,+ "survivors": 1251+ }+ }+ },+ {+ "trial": 10,+ "skill_counts": {+ "renee_s1": 47,+ "ambusher": 14+ },+ "attacker": {+ "survivors": 0,+ "infantry": {+ "kills": 382,+ "survivors": 0+ },+ "lancer": {+ "kills": 2346,+ "survivors": 0+ }+ },+ "defender": {+ "survivors": 2209,+ "infantry": {+ "kills": 555,+ "survivors": 690+ },+ "marksman": {+ "kills": 2070,+ "survivors": 1519+ }+ }+ }+ ],+ "data_quality_notes": [+ "Trial 2's supplied attacker troop survivors sum to 590 (9 Infantry + 581 Lancers), while its supplied attacker total is 581.",+ "Trial 2's supplied defender troop survivors sum to 0, while its supplied defender total is 9. Values are preserved exactly as supplied."+ ]+ }+ }+]Index: testcases/heroes_unittests/Flint_tc.json===================================================================--- testcases/heroes_unittests/Flint_tc.json prev run+++ testcases/heroes_unittests/Flint_tc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "flint_tc_1","time": "2025-07-20 - 03:49","attacker": {"name": "daut","heroes": {},@@ -104,9 +104,9 @@}]},{- "test_id": "daut_viper_2",+ "test_id": "flint_tc_2","time": "2025-07-20 - 04:07","attacker": {"name": "daut","heroes": {},Index: testcases/heroes_unittests/Hector_tc.json===================================================================--- testcases/heroes_unittests/Hector_tc.json prev run+++ testcases/heroes_unittests/Hector_tc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "hector_tc_1","time": "2025-07-21 - 01:06","attacker": {"name": "daut","heroes": {},Index: testcases/heroes_unittests/Jessie_tc_nc.json===================================================================--- testcases/heroes_unittests/Jessie_tc_nc.json prev run+++ testcases/heroes_unittests/Jessie_tc_nc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "jessie_tc_1","time": "2025-07-18 - 02:17","attacker": {"name": "daut","heroes": {},@@ -77,9 +77,9 @@"defender": 61}},{- "test_id": "daut_viper_2",+ "test_id": "jessie_tc_2","time": "2025-07-18 - 03:33","attacker": {"name": "daut","heroes": {},@@ -155,9 +155,9 @@"defender": 89}},{- "test_id": "daut_viper_3",+ "test_id": "jessie_tc_3","time": "2025-07-18 - 02:34","attacker": {"name": "daut","heroes": {},@@ -223,9 +223,9 @@"defender": 0}},{- "test_id": "daut_viper_4",+ "test_id": "jessie_tc_4","time": "2025-07-18 - 02:38","attacker": {"name": "daut","heroes": {},@@ -299,9 +299,9 @@"defender": 0}},{- "test_id": "daut_viper_5",+ "test_id": "jessie_tc_5","time": "2025-07-18 - 02:40","attacker": {"name": "daut","heroes": {@@ -375,9 +375,9 @@"defender": 0}},{- "test_id": "daut_viper_6",+ "test_id": "jessie_tc_6","time": "2025-07-18 - 02:44","attacker": {"name": "daut","heroes": {},@@ -460,9 +460,9 @@"defender": 96}},{- "test_id": "daut_viper_7",+ "test_id": "jessie_tc_7","time": "2025-07-18 - 02:47","attacker": {"name": "daut","heroes": {},@@ -545,9 +545,9 @@"defender": 74}},{- "test_id": "daut_viper_8",+ "test_id": "jessie_tc_8","time": "2025-07-18 - 02:57","attacker": {"name": "daut","heroes": {Index: testcases/heroes_unittests/Mia_tc.json===================================================================--- testcases/heroes_unittests/Mia_tc.json prev run+++ testcases/heroes_unittests/Mia_tc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "mia_tc_1","time": "2025-07-18 - 23:40","attacker": {"name": "daut","heroes": {},@@ -84,9 +84,9 @@"defender": 181}},{- "test_id": "daut_viper_2",+ "test_id": "mia_tc_2","time": "2025-07-18 - 23:41","attacker": {"name": "daut","heroes": {},@@ -195,9 +195,9 @@}]},{- "test_id": "daut_viper_3",+ "test_id": "mia_tc_3","time": "2025-07-19 - 01:02","attacker": {"name": "daut","heroes": {@@ -300,9 +300,9 @@}]},{- "test_id": "daut_viper_4",+ "test_id": "mia_tc_4","time": "2025-07-20 - 02:42","attacker": {"name": "daut","heroes": {@@ -383,9 +383,9 @@"defender": 0}},{- "test_id": "daut_viper_5",+ "test_id": "mia_tc_5","time": "2025-07-20 - 02:48","attacker": {"name": "daut","heroes": {Index: testcases/heroes_unittests/Molly_tc.json===================================================================--- testcases/heroes_unittests/Molly_tc.json prev run+++ testcases/heroes_unittests/Molly_tc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "molly_tc_1","time": "2025-07-18 - 02:17","attacker": {"name": "daut","heroes": {},@@ -77,9 +77,9 @@"defender": 61}},{- "test_id": "daut_viper_2",+ "test_id": "molly_tc_2","time": "2025-07-18 - 03:33","attacker": {"name": "daut","heroes": {},@@ -155,9 +155,9 @@"defender": 89}},{- "test_id": "daut_viper_3",+ "test_id": "molly_tc_3","time": "2025-07-18 - 02:34","attacker": {"name": "daut","heroes": {},@@ -223,9 +223,9 @@"defender": 0}},{- "test_id": "daut_viper_4",+ "test_id": "molly_tc_4","time": "2025-07-18 - 04:43","attacker": {"name": "daut","heroes": {@@ -308,9 +308,9 @@"defender": 68}},{- "test_id": "daut_viper_5",+ "test_id": "molly_tc_5","time": "2025-07-18 - 05:37","attacker": {"name": "daut","heroes": {},Index: testcases/heroes_unittests/Reina_tc.json===================================================================--- testcases/heroes_unittests/Reina_tc.json prev run+++ testcases/heroes_unittests/Reina_tc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "reina_tc_1","time": "2025-07-18 - 18:45","attacker": {"name": "daut2x","heroes": {},@@ -82,9 +82,9 @@"defender": 62}},{- "test_id": "daut_viper_2",+ "test_id": "reina_tc_2","time": "2025-07-18 - 18:55","attacker": {"name": "daut2x","heroes": {},@@ -169,9 +169,9 @@"defender": 40}},{- "test_id": "daut_viper_4",+ "test_id": "reina_tc_4","time": "2025-07-18 - 20:11","attacker": {"name": "daut","heroes": {},@@ -276,9 +276,9 @@}]},{- "test_id": "daut_viper_5",+ "test_id": "reina_tc_5","time": "2025-07-18 - 20:16","attacker": {"name": "daut","heroes": {},Index: testcases/heroes_unittests/Seo-yoon_tc_nc.json===================================================================--- testcases/heroes_unittests/Seo-yoon_tc_nc.json prev run+++ testcases/heroes_unittests/Seo-yoon_tc_nc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "seoyoon_tc_1","time": "2025-07-18 - 02:17","attacker": {"name": "daut","heroes": {},@@ -77,9 +77,9 @@"defender": 61}},{- "test_id": "daut_viper_6",+ "test_id": "seoyoon_tc_6","time": "2025-07-18 - 03:33","attacker": {"name": "daut","heroes": {},@@ -155,9 +155,9 @@"defender": 89}},{- "test_id": "daut_viper_3",+ "test_id": "seoyoon_tc_3","time": "2025-07-18 - 02:34","attacker": {"name": "daut","heroes": {},@@ -223,9 +223,9 @@"defender": 0}},{- "test_id": "daut_viper_4",+ "test_id": "seoyoon_tc_4","time": "2025-07-18 - 04:20","attacker": {"name": "daut","heroes": {@@ -306,9 +306,9 @@"defender": 0}},{- "test_id": "daut_viper_5",+ "test_id": "seoyoon_tc_5","time": "2025-07-18 - 04:24","attacker": {"name": "daut","heroes": {},@@ -389,9 +389,9 @@"defender": 0}},{- "test_id": "daut_viper_6",+ "test_id": "seoyoon_tc_6","time": "2025-07-18 - 04:28","attacker": {"name": "daut","heroes": {@@ -477,9 +477,9 @@"defender": 0}},{- "test_id": "daut_viper_7",+ "test_id": "seoyoon_tc_7","time": "2025-07-18 - 04:36","attacker": {"name": "daut","heroes": {Index: testcases/heroes_unittests/Zinman_tc.json===================================================================--- testcases/heroes_unittests/Zinman_tc.json prev run+++ testcases/heroes_unittests/Zinman_tc.json this run@@ -1,7 +1,7 @@[{- "test_id": "daut_viper_1",+ "test_id": "zinman_tc_1","time": "2025-07-18 - 23:01","attacker": {"name": "daut","heroes": {},@@ -76,9 +76,9 @@"defender": 122}},{- "test_id": "daut_viper_2",+ "test_id": "zinman_tc_2","time": "2025-07-18 - 23:16","attacker": {"name": "daut","heroes": {},@@ -162,9 +162,9 @@"defender": 0}},{- "test_id": "daut_viper_3",+ "test_id": "zinman_tc_3","time": "2025-07-18 - 23:31","attacker": {"name": "daut","heroes": {},
Show raw per-run patches
Run A dirty state patch
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@@ -69,8 +69,8 @@When enabled:-- the stat block from `hero_generation_stats.json` is added to every troop type's player stats;-- generation stats from all **main** heroes are summed;+- each main hero's generation stat block is added only to the player-stat block for that hero's troop type;+- generation stats from multiple main heroes of the same troop type are summed in that troop type's block;- joiner heroes do not contribute generation stats;- skill level does not change the generation stat block.
Run B dirty state patch
diff --git a/simulator/src/tooling/parityMetrics.ts b/simulator/src/tooling/parityMetrics.ts--- a/simulator/src/tooling/parityMetrics.ts+++ b/simulator/src/tooling/parityMetrics.ts@@ -52,7 +52,7 @@if (options.deterministic) {statType = "deterministic";passes = Math.abs(biasPct) <= deterministicLimit;- } else if (options.candidate.sigma === 0) {+ } else if (options.candidate.sigma === 0 && options.reference.sigma === 0) {statType = "zero_var";passes = Math.abs(biasPct) <= deterministicLimit;} else if (options.reference.n <= 1) {@@ -63,10 +63,21 @@passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;} else {statType = "t";- sem = options.candidate.sigma * Math.sqrt(1 / Math.max(options.candidate.n, 1) + 1 / options.reference.n);- stat = sem === 0 ? null : round(biasRaw / sem, 4);- p = stat === null ? null : round(2 * (1 - normalCdf(Math.abs(stat))), 6);- passes = stat === null || Math.abs(stat) <= zThreshold || Math.abs(biasPct) <= minBiasPct;+ const candidateVariance = (options.candidate.sigma ** 2) / Math.max(options.candidate.n, 1);+ const referenceVariance = (options.reference.sigma ** 2) / options.reference.n;+ sem = Math.sqrt(candidateVariance + referenceVariance);+ const rawStat = sem === 0 ? null : biasRaw / sem;+ const degreesOfFreedom = welchDegreesOfFreedom(+ candidateVariance,+ options.candidate.n,+ referenceVariance,+ options.reference.n+ );+ const rawP = rawStat === null ? null : studentTwoSidedP(rawStat, degreesOfFreedom);+ stat = rawStat === null ? null : round(rawStat, 4);+ p = rawP === null ? null : round(rawP, 6);+ const alpha = 2 * (1 - normalCdf(zThreshold));+ passes = rawP === null || rawP >= alpha || Math.abs(biasPct) <= minBiasPct;}return {@@ -104,6 +115,86 @@return 0.5 * (1 + erf(x / Math.SQRT2));}+function welchDegreesOfFreedom(candidateVariance: number, candidateN: number, referenceVariance: number, referenceN: number): number {+ const numerator = (candidateVariance + referenceVariance) ** 2;+ const denominator = varianceDegreesTerm(candidateVariance, candidateN) + varianceDegreesTerm(referenceVariance, referenceN);+ return denominator === 0 ? Number.POSITIVE_INFINITY : numerator / denominator;+}++function varianceDegreesTerm(variance: number, n: number): number {+ return n <= 1 ? 0 : (variance ** 2) / (n - 1);+}++function studentTwoSidedP(stat: number, degreesOfFreedom: number): number {+ if (!Number.isFinite(degreesOfFreedom)) return 2 * (1 - normalCdf(Math.abs(stat)));+ const x = degreesOfFreedom / (degreesOfFreedom + stat ** 2);+ return regularizedIncompleteBeta(x, degreesOfFreedom / 2, 0.5);+}++function regularizedIncompleteBeta(x: number, a: number, b: number): number {+ if (x <= 0) return 0;+ if (x >= 1) return 1;+ const scale = Math.exp(logGamma(a + b) - logGamma(a) - logGamma(b) + a * Math.log(x) + b * Math.log1p(-x));+ if (x < (a + 1) / (a + b + 2)) return (scale * betaContinuedFraction(x, a, b)) / a;+ return 1 - (scale * betaContinuedFraction(1 - x, b, a)) / b;+}++function betaContinuedFraction(x: number, a: number, b: number): number {+ const maxIterations = 200;+ const epsilon = 3e-14;+ const minimum = 1e-300;+ const qab = a + b;+ const qap = a + 1;+ const qam = a - 1;+ let c = 1;+ let d = 1 - (qab * x) / qap;+ if (Math.abs(d) < minimum) d = minimum;+ d = 1 / d;+ let result = d;++ for (let iteration = 1; iteration <= maxIterations; iteration += 1) {+ const even = iteration * 2;+ let coefficient = (iteration * (b - iteration) * x) / ((qam + even) * (a + even));+ d = 1 + coefficient * d;+ if (Math.abs(d) < minimum) d = minimum;+ c = 1 + coefficient / c;+ if (Math.abs(c) < minimum) c = minimum;+ d = 1 / d;+ result *= d * c;++ coefficient = -((a + iteration) * (qab + iteration) * x) / ((a + even) * (qap + even));+ d = 1 + coefficient * d;+ if (Math.abs(d) < minimum) d = minimum;+ c = 1 + coefficient / c;+ if (Math.abs(c) < minimum) c = minimum;+ d = 1 / d;+ const delta = d * c;+ result *= delta;+ if (Math.abs(delta - 1) < epsilon) break;+ }++ return result;+}++function logGamma(value: number): number {+ const coefficients = [+ 676.5203681218851,+ -1259.1392167224028,+ 771.3234287776531,+ -176.6150291621406,+ 12.507343278686905,+ -0.13857109526572012,+ 9.984369578019572e-6,+ 1.5056327351493116e-7+ ];+ if (value < 0.5) return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * value)) - logGamma(1 - value);+ let series = 0.9999999999998099;+ const shifted = value - 1;+ for (let index = 0; index < coefficients.length; index += 1) series += coefficients[index] / (shifted + index + 1);+ const t = shifted + coefficients.length - 0.5;+ return 0.5 * Math.log(2 * Math.PI) + (shifted + 0.5) * Math.log(t) - t + Math.log(series);+}+function erf(x: number): number {const sign = x < 0 ? -1 : 1;const ax = Math.abs(x);diff --git a/simulator/src/tooling/testcases.test.ts b/simulator/src/tooling/testcases.test.ts--- a/simulator/src/tooling/testcases.test.ts+++ b/simulator/src/tooling/testcases.test.ts@@ -404,6 +404,22 @@assert.equal(metrics.stat, -4.4459);});+test("compareOutcomeDistribution includes noisy game samples in a Welch t comparison", () => {+ const metrics = compareOutcomeDistribution({+ candidate: { n: 100, mu: -1149.6, sigma: 1578.2 },+ reference: { n: 10, mu: 179.3, sigma: 2006.6 },+ initialTroops: 17_500,+ deterministic: false,+ thresholds: { z_threshold: 2, min_bias_pct: 0.5 }+ });++ assert.equal(metrics.stat_type, "t");+ assert.equal(metrics.sem, 653.87);+ assert.equal(metrics.stat, -2.0323);+ assert.equal(metrics.p, 0.069131);+ assert.equal(metrics.passes, true);+});+test("applyBenjaminiHochberg fills q values on p-valued comparisons", () => {const rows = [{ p: 0.01, q: null },diff --git a/simulator/src/tooling/testcases.ts b/simulator/src/tooling/testcases.ts--- a/simulator/src/tooling/testcases.ts+++ b/simulator/src/tooling/testcases.ts@@ -11,7 +11,7 @@import { applyBenjaminiHochberg, compareOutcomeDistribution, type ParityComparisonMetrics } from "./parityMetrics";import { prepareBattle, runPrepared } from "../simulator";import { DamageAggregationError } from "../staticDamageProfile";-import type { BattleInput, BattleResult, FighterInput, SimulatorConfig, StatBlock, UnitType } from "../types";+import type { BattleInput, BattleResult, FighterInput, SimulationMode, SimulatorConfig, StatBlock, UnitType } from "../types";const DEFAULT_STOCHASTIC_REPEAT = 100;const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;@@ -146,6 +146,8 @@input: BattleInput;repeat: number;seed?: string | number;+ includeSamples?: boolean;+ simulationMode?: SimulationMode;}export interface TestcaseExecutionResult {@@ -619,7 +621,11 @@// Resolve the battle once and reuse it across every seeded sample of this case.const compiled = prepareBattle(job.input, config);const baseSeed = job.seed ?? job.input.seed;- const sample = (iteration: number) => runPrepared(compiled, sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration));+ const sample = (iteration: number) => runPrepared(+ compiled,+ sampleSeed(baseSeed, job.file, job.testcaseId, job.index, iteration),+ job.simulationMode ? { mode: job.simulationMode } : {}+ );let result = sample(0);const firstScore = battleScoreDelta(result);if (firstScore !== undefined) {@@ -645,7 +651,7 @@result,deterministic: result.randomness.deterministic,sampleCount,- simulatorStats: sampleStats(samples),+ simulatorStats: sampleStats(samples, { includeSamples: job.includeSamples }),simulatorScoreDelta: battleScoreDelta(result),simulatorSampleOutcomes: sampleOutcomes,simulatorSampleDeltas: sampleDeltas,diff --git a/testcases/1-testcases_no-heroes_t6_single-type_nc.json b/testcases/1-testcases_no-heroes_t6_single-type_nc.json--- a/testcases/1-testcases_no-heroes_t6_single-type_nc.json+++ b/testcases/1-testcases_no-heroes_t6_single-type_nc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_9",+ "test_id": "no_heroes_single_t6__9","time": "2025-07-13 - 12:02","attacker": {"name": "daut",@@ -68,7 +68,7 @@}},{- "test_id": "daut_viper_10",+ "test_id": "no_heroes_single_t6__10","time": "2025-07-13 - 12:07","attacker": {"name": "daut",@@ -136,7 +136,7 @@}},{- "test_id": "daut_viper_11",+ "test_id": "no_heroes_single_t6__11","time": "2025-07-13 - 12:08","attacker": {"name": "daut",@@ -204,7 +204,7 @@}},{- "test_id": "daut_viper_12",+ "test_id": "no_heroes_single_t6__12","time": "2025-07-13 - 12:15","attacker": {"name": "daut",@@ -272,7 +272,7 @@}},{- "test_id": "daut_viper_13",+ "test_id": "no_heroes_single_t6__13","time": "2025-07-13 - 12:21","attacker": {"name": "daut",@@ -340,7 +340,7 @@}},{- "test_id": "daut_viper_14",+ "test_id": "no_heroes_single_t6__14","time": "2025-07-13 - 12:24","attacker": {"name": "daut",@@ -408,7 +408,7 @@}},{- "test_id": "daut_viper_15",+ "test_id": "no_heroes_single_t6__15","time": "2025-07-13 - 12:27","attacker": {"name": "daut",@@ -476,7 +476,7 @@}},{- "test_id": "daut_viper_16",+ "test_id": "no_heroes_single_t6__16","time": "2025-07-13 - 12:29","attacker": {"name": "daut",@@ -544,7 +544,7 @@}},{- "test_id": "daut_viper_17",+ "test_id": "no_heroes_single_t6__17","time": "2025-07-13 - 12:32","attacker": {"name": "daut",@@ -614,7 +614,7 @@}},{- "test_id": "daut_viper_18",+ "test_id": "no_heroes_single_t6__18","time": "2025-07-13 - 12:34","attacker": {"name": "daut",@@ -684,7 +684,7 @@}},{- "test_id": "daut_viper_19",+ "test_id": "no_heroes_single_t6__19","time": "2025-07-13 - 12:36","attacker": {"name": "daut",@@ -754,7 +754,7 @@}},{- "test_id": "daut_viper_20",+ "test_id": "no_heroes_single_t6__20","time": "2025-07-13 - 12:39","attacker": {"name": "daut",@@ -824,7 +824,7 @@}},{- "test_id": "daut_viper_21",+ "test_id": "no_heroes_single_t6__21","time": "2025-07-13 - 12:42","attacker": {"name": "daut",@@ -894,7 +894,7 @@}},{- "test_id": "daut_viper_22",+ "test_id": "no_heroes_single_t6__22","time": "2025-07-13 - 12:43","attacker": {"name": "daut",@@ -964,7 +964,7 @@}},{- "test_id": "daut_viper_23",+ "test_id": "no_heroes_single_t6__23","time": "2025-07-13 - 12:45","attacker": {"name": "daut",@@ -1034,7 +1034,7 @@}},{- "test_id": "daut_viper_24",+ "test_id": "no_heroes_single_t6__24","time": "2025-07-13 - 12:47","attacker": {"name": "daut",@@ -1104,7 +1104,7 @@}},{- "test_id": "daut_viper_25",+ "test_id": "no_heroes_single_t6__25","time": "2025-07-13 - 12:50","attacker": {"name": "daut",@@ -1174,7 +1174,7 @@}},{- "test_id": "daut_viper_26",+ "test_id": "no_heroes_single_t6__26","time": "2025-07-13 - 12:54","attacker": {"name": "daut",diff --git a/testcases/2-testcases_no-heroes_t6_mixed_nc.json b/testcases/2-testcases_no-heroes_t6_mixed_nc.json--- a/testcases/2-testcases_no-heroes_t6_mixed_nc.json+++ b/testcases/2-testcases_no-heroes_t6_mixed_nc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "no_heroes_t6_mixed_1","time": "2025-07-12 - 22:59","attacker": {"name": "daut",@@ -78,7 +78,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "no_heroes_t6_mixed_2","time": "2025-07-18 - 02:17","attacker": {"name": "daut",@@ -156,7 +156,7 @@}},{- "test_id": "daut_viper_3",+ "test_id": "no_heroes_t6_mixed_3","time": "2025-07-18 - 02:30","attacker": {"name": "daut",@@ -234,7 +234,7 @@}},{- "test_id": "daut_viper_4",+ "test_id": "no_heroes_t6_mixed_4","time": "2025-07-18 - 03:17","attacker": {"name": "daut",@@ -312,7 +312,7 @@}},{- "test_id": "daut_viper_5",+ "test_id": "no_heroes_t6_mixed_5","time": "2025-07-18 - 03:24","attacker": {"name": "daut",@@ -390,7 +390,7 @@}},{- "test_id": "daut_viper_6",+ "test_id": "no_heroes_t6_mixed_6","time": "2025-07-18 - 03:33","attacker": {"name": "daut",diff --git a/testcases/3-testcases_mixed-heroes-not-verified.json b/testcases/3-testcases_mixed-heroes-not-verified.json--- a/testcases/3-testcases_mixed-heroes-not-verified.json+++ b/testcases/3-testcases_mixed-heroes-not-verified.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "no_heroes_t6_mixed_2x_1","time": "2025-07-12 - 23:09","attacker": {"name": "daut2x",@@ -85,7 +85,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "no_heroes_t6_mixed_2x_2","time": "2025-07-12 - 23:17","attacker": {"name": "daut2x",@@ -167,7 +167,7 @@}},{- "test_id": "daut_viper_3",+ "test_id": "no_heroes_t6_mixed_2x_3","time": "2025-07-13 - 13:45","attacker": {"name": "daut2x",@@ -281,7 +281,7 @@}},{- "test_id": "daut_viper_4",+ "test_id": "no_heroes_t6_mixed_2x_4","time": "2025-07-13 - 16:51","attacker": {"name": "daut2x",@@ -393,7 +393,7 @@}},{- "test_id": "daut_viper_5",+ "test_id": "no_heroes_t6_mixed_2x_5","time": "2025-07-13 - 16:57","attacker": {"name": "daut2x",@@ -505,7 +505,7 @@}},{- "test_id": "daut_viper_7",+ "test_id": "no_heroes_t6_mixed_2x_6","time": "2025-07-13 - 17:03","attacker": {"name": "daut2x",diff --git a/testcases/heroes_unittests/Flint_tc.json b/testcases/heroes_unittests/Flint_tc.json--- a/testcases/heroes_unittests/Flint_tc.json+++ b/testcases/heroes_unittests/Flint_tc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "flint_tc_1","time": "2025-07-20 - 03:49","attacker": {"name": "daut",@@ -105,7 +105,7 @@]},{- "test_id": "daut_viper_2",+ "test_id": "flint_tc_2","time": "2025-07-20 - 04:07","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Hector_tc.json b/testcases/heroes_unittests/Hector_tc.json--- a/testcases/heroes_unittests/Hector_tc.json+++ b/testcases/heroes_unittests/Hector_tc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "hector_tc_1","time": "2025-07-21 - 01:06","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Jessie_tc_nc.json b/testcases/heroes_unittests/Jessie_tc_nc.json--- a/testcases/heroes_unittests/Jessie_tc_nc.json+++ b/testcases/heroes_unittests/Jessie_tc_nc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "jessie_tc_1","time": "2025-07-18 - 02:17","attacker": {"name": "daut",@@ -78,7 +78,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "jessie_tc_2","time": "2025-07-18 - 03:33","attacker": {"name": "daut",@@ -156,7 +156,7 @@}},{- "test_id": "daut_viper_3",+ "test_id": "jessie_tc_3","time": "2025-07-18 - 02:34","attacker": {"name": "daut",@@ -224,7 +224,7 @@}},{- "test_id": "daut_viper_4",+ "test_id": "jessie_tc_4","time": "2025-07-18 - 02:38","attacker": {"name": "daut",@@ -300,7 +300,7 @@}},{- "test_id": "daut_viper_5",+ "test_id": "jessie_tc_5","time": "2025-07-18 - 02:40","attacker": {"name": "daut",@@ -376,7 +376,7 @@}},{- "test_id": "daut_viper_6",+ "test_id": "jessie_tc_6","time": "2025-07-18 - 02:44","attacker": {"name": "daut",@@ -461,7 +461,7 @@}},{- "test_id": "daut_viper_7",+ "test_id": "jessie_tc_7","time": "2025-07-18 - 02:47","attacker": {"name": "daut",@@ -546,7 +546,7 @@}},{- "test_id": "daut_viper_8",+ "test_id": "jessie_tc_8","time": "2025-07-18 - 02:57","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Mia_tc.json b/testcases/heroes_unittests/Mia_tc.json--- a/testcases/heroes_unittests/Mia_tc.json+++ b/testcases/heroes_unittests/Mia_tc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "mia_tc_1","time": "2025-07-18 - 23:40","attacker": {"name": "daut",@@ -85,7 +85,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "mia_tc_2","time": "2025-07-18 - 23:41","attacker": {"name": "daut",@@ -196,7 +196,7 @@]},{- "test_id": "daut_viper_3",+ "test_id": "mia_tc_3","time": "2025-07-19 - 01:02","attacker": {"name": "daut",@@ -301,7 +301,7 @@]},{- "test_id": "daut_viper_4",+ "test_id": "mia_tc_4","time": "2025-07-20 - 02:42","attacker": {"name": "daut",@@ -384,7 +384,7 @@}},{- "test_id": "daut_viper_5",+ "test_id": "mia_tc_5","time": "2025-07-20 - 02:48","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Molly_tc.json b/testcases/heroes_unittests/Molly_tc.json--- a/testcases/heroes_unittests/Molly_tc.json+++ b/testcases/heroes_unittests/Molly_tc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "molly_tc_1","time": "2025-07-18 - 02:17","attacker": {"name": "daut",@@ -78,7 +78,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "molly_tc_2","time": "2025-07-18 - 03:33","attacker": {"name": "daut",@@ -156,7 +156,7 @@}},{- "test_id": "daut_viper_3",+ "test_id": "molly_tc_3","time": "2025-07-18 - 02:34","attacker": {"name": "daut",@@ -224,7 +224,7 @@}},{- "test_id": "daut_viper_4",+ "test_id": "molly_tc_4","time": "2025-07-18 - 04:43","attacker": {"name": "daut",@@ -309,7 +309,7 @@}},{- "test_id": "daut_viper_5",+ "test_id": "molly_tc_5","time": "2025-07-18 - 05:37","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Reina_tc.json b/testcases/heroes_unittests/Reina_tc.json--- a/testcases/heroes_unittests/Reina_tc.json+++ b/testcases/heroes_unittests/Reina_tc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "reina_tc_1","time": "2025-07-18 - 18:45","attacker": {"name": "daut2x",@@ -83,7 +83,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "reina_tc_2","time": "2025-07-18 - 18:55","attacker": {"name": "daut2x",@@ -170,7 +170,7 @@}},{- "test_id": "daut_viper_4",+ "test_id": "reina_tc_4","time": "2025-07-18 - 20:11","attacker": {"name": "daut",@@ -277,7 +277,7 @@]},{- "test_id": "daut_viper_5",+ "test_id": "reina_tc_5","time": "2025-07-18 - 20:16","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Seo-yoon_tc_nc.json b/testcases/heroes_unittests/Seo-yoon_tc_nc.json--- a/testcases/heroes_unittests/Seo-yoon_tc_nc.json+++ b/testcases/heroes_unittests/Seo-yoon_tc_nc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "seoyoon_tc_1","time": "2025-07-18 - 02:17","attacker": {"name": "daut",@@ -78,7 +78,7 @@}},{- "test_id": "daut_viper_6",+ "test_id": "seoyoon_tc_6","time": "2025-07-18 - 03:33","attacker": {"name": "daut",@@ -156,7 +156,7 @@}},{- "test_id": "daut_viper_3",+ "test_id": "seoyoon_tc_3","time": "2025-07-18 - 02:34","attacker": {"name": "daut",@@ -224,7 +224,7 @@}},{- "test_id": "daut_viper_4",+ "test_id": "seoyoon_tc_4","time": "2025-07-18 - 04:20","attacker": {"name": "daut",@@ -307,7 +307,7 @@}},{- "test_id": "daut_viper_5",+ "test_id": "seoyoon_tc_5","time": "2025-07-18 - 04:24","attacker": {"name": "daut",@@ -390,7 +390,7 @@}},{- "test_id": "daut_viper_6",+ "test_id": "seoyoon_tc_6","time": "2025-07-18 - 04:28","attacker": {"name": "daut",@@ -478,7 +478,7 @@}},{- "test_id": "daut_viper_7",+ "test_id": "seoyoon_tc_7","time": "2025-07-18 - 04:36","attacker": {"name": "daut",diff --git a/testcases/heroes_unittests/Zinman_tc.json b/testcases/heroes_unittests/Zinman_tc.json--- a/testcases/heroes_unittests/Zinman_tc.json+++ b/testcases/heroes_unittests/Zinman_tc.json@@ -1,6 +1,6 @@[{- "test_id": "daut_viper_1",+ "test_id": "zinman_tc_1","time": "2025-07-18 - 23:01","attacker": {"name": "daut",@@ -77,7 +77,7 @@}},{- "test_id": "daut_viper_2",+ "test_id": "zinman_tc_2","time": "2025-07-18 - 23:16","attacker": {"name": "daut",@@ -163,7 +163,7 @@}},{- "test_id": "daut_viper_3",+ "test_id": "zinman_tc_3","time": "2025-07-18 - 23:31","attacker": {"name": "daut",