← Back to Runs
Run Detail
c666bc1b-5ca4-4223-846a-44f77fbc9894
Started17/06/2026, 15:57:04
Git SHA29120cde
Statedirty
Avg Error %0.20%
BH Sig Count5
Total Cases178
Passing158
Run Report—
Code Changes Since Previous Run
Index: simulator/config/hero_definitions/Greg.json===================================================================--- simulator/config/hero_definitions/Greg.json prev run+++ simulator/config/hero_definitions/Greg.json this run@@ -24,9 +24,9 @@24,32,40],- "same_effect_stacking": "add",+ "same_effect_stacking": "max","units": {"applies_to": "all","applies_vs": "any"},Index: simulator/config/hero_definitions/Mia.json===================================================================--- simulator/config/hero_definitions/Mia.json prev run+++ simulator/config/hero_definitions/Mia.json this run@@ -29,9 +29,10 @@"applies_to": "target"},"duration": {"type": "turn",- "value": 1+ "value": 1,+ "delay": 1},"same_effect_stacking": "max"}}Index: simulator/src/battleInputBuilder.ts===================================================================--- simulator/src/battleInputBuilder.ts prev run+++ simulator/src/battleInputBuilder.ts this run@@ -0,0 +1,66 @@+import type { BattleInput, FighterInput, SideId, SimulatorConfig } from "./types";+import { applyHeroGenerationStats } from "./resolve";++/**+ * Ergonomic, order-independent constructor for a BattleInput.+ *+ * This is build-time scaffolding only — it does not run a simulation. Its job is to produce a+ * BattleInput whose FighterInput.stats are the final, authoritative player stats, so the+ * simulator core never has to massage the input. For example, addHeroGenerationStats() bakes+ * the main heroes' generation stats into each fighter's stat block, which the simulator used to+ * apply itself via the (now removed) hero_generation_stats mechanic.+ */+export class BattleInputBuilder {+ private readonly fighters: Partial<Record<SideId, FighterInput>> = {};+ private seedValue?: string | number;+ private maxRoundsValue?: number;+ private engagementTypeValue?: string;+ private bakeHeroGenerationStats = false;++ constructor(private readonly config: SimulatorConfig) {}++ fighter(side: SideId, input: FighterInput): this {+ this.fighters[side] = input;+ return this;+ }++ seed(seed: string | number): this {+ this.seedValue = seed;+ return this;+ }++ maxRounds(maxRounds: number): this {+ this.maxRoundsValue = maxRounds;+ return this;+ }++ engagement(engagementType: string): this {+ this.engagementTypeValue = engagementType;+ return this;+ }++ addHeroGenerationStats(add = true): this {+ this.bakeHeroGenerationStats = add;+ return this;+ }++ build(): BattleInput {+ const attacker = this.requireFighter("attacker");+ const defender = this.requireFighter("defender");+ const prepare = (fighter: FighterInput): FighterInput =>+ this.bakeHeroGenerationStats ? applyHeroGenerationStats(fighter, this.config) : fighter;+ return {+ attacker: prepare(attacker),+ defender: prepare(defender),+ ...(this.seedValue !== undefined ? { seed: this.seedValue } : {}),+ ...(this.maxRoundsValue !== undefined ? { maxRounds: this.maxRoundsValue } : {}),+ ...(this.engagementTypeValue !== undefined ? { engagement_type: this.engagementTypeValue } : {})+ };+ }++ private requireFighter(side: SideId): FighterInput {+ const fighter = this.fighters[side];+ if (!fighter) throw new Error(`BattleInputBuilder is missing the ${side} fighter`);+ return fighter;+ }+}Index: simulator/src/calibration.ts===================================================================--- simulator/src/calibration.ts prev run+++ simulator/src/calibration.ts this run@@ -1,6 +1,6 @@import { readdirSync, readFileSync, statSync } from "node:fs";-import { dirname, relative, resolve } from "node:path";+import { dirname, resolve } from "node:path";import { fileURLToPath } from "node:url";export interface CalibrationCaseComparison {file: string;@@ -134,69 +134,8 @@}return [...variants];}-export function addCalibrationTableRow(- comparison: CalibrationComparison,- caseReport: {- file: string;- testcaseId: string;- index: number;- simulatorScoreDelta?: number;- simulatorStats?: SampleStats;- calibration?: CalibrationCaseComparison;- }-): CalibrationComparisonRow {- const calibration = caseReport.calibration;- const simulator = caseReport.simulatorStats;- const simulatorVsBaseline = compareDistributions(simulator, calibration?.muSim, calibration?.sigmaSim, calibration?.nSim, comparison.thresholds, calibration?.statType);- const simulatorVsGame = compareDistributions(simulator, calibration?.muGame, calibration?.sigmaGame, calibration?.nGame, comparison.thresholds, calibration?.statType);- const simulatorVsGameRaw = simulatorVsGame.biasRaw;- const simulatorVsGamePct = simulatorVsGame.biasPct;- const row: CalibrationComparisonRow = {- file: relativeDisplayPath(caseReport.file),- testcaseId: caseReport.testcaseId,- idx: caseReport.index,- matched: !!calibration,- nSim: calibration?.nSim,- muSim: calibration?.muSim,- sigmaSim: calibration?.sigmaSim,- nGame: calibration?.nGame,- muGame: calibration?.muGame,- sigmaGame: calibration?.sigmaGame,- statType: calibration?.statType,- referenceBiasPct: calibration?.biasPct,- referencePasses: calibration?.passes,- passes: calibration?.passes,- biasRaw: calibration?.biasRaw,- biasPct: calibration?.biasPct,- sem: calibration?.sem,- p: calibration?.p,- q: calibration?.q,- simulatorScoreDelta: caseReport.simulatorScoreDelta,- simulator,- simulatorVsBaseline,- simulatorVsGame,- simulatorVsGameRaw,- simulatorVsGamePct,- simulatorPasses: simulatorVsGame.passes,- simulatorN: simulator?.n,- simulatorMu: simulator?.mu,- simulatorSigma: simulator?.sigma,- simulatorSem: simulator?.sem,- simulatorVsBaselineBiasRaw: simulatorVsBaseline.biasRaw,- simulatorVsBaselineBiasPct: simulatorVsBaseline.biasPct,- simulatorVsBaselineZ: simulatorVsBaseline.z,- simulatorVsBaselinePasses: simulatorVsBaseline.passes,- simulatorVsGameBiasRaw: simulatorVsGame.biasRaw,- simulatorVsGameBiasPct: simulatorVsGame.biasPct,- simulatorVsGameZ: simulatorVsGame.z,- simulatorVsGamePasses: simulatorVsGame.passes- };- comparison.table.push(row);- return row;-}-export function sampleStats(samples: number[], options: { includeSamples?: boolean } = {}): SampleStats {const n = samples.length;const mu = n > 0 ? samples.reduce((sum, value) => sum + value, 0) / n : 0;const variance = n > 1 ? samples.reduce((sum, value) => sum + (value - mu) ** 2, 0) / (n - 1) : 0;@@ -292,64 +231,8 @@.filter((entry): entry is readonly [string, number] => entry[1] !== undefined);return entries.length > 0 ? Object.fromEntries(entries) : undefined;}-function compareDistributions(- simulator: SampleStats | undefined,- referenceMu: number | undefined,- referenceSigma: number | undefined,- referenceN: number | undefined,- thresholds?: Record<string, number>,- referenceStatType?: string-): DistributionCompatibility {- if (!simulator || referenceMu === undefined || referenceSigma === undefined || referenceN === undefined) return { statType: "unmatched" };- const biasRaw = simulator.mu - referenceMu;- const biasPct = percentDelta(biasRaw, referenceMu);- const combinedSem = Math.sqrt((simulator.sigma ** 2) / Math.max(1, simulator.n) + (referenceSigma ** 2) / Math.max(1, referenceN));- const deterministic = referenceStatType === "deterministic" || (simulator.sigma === 0 && referenceSigma === 0);- if (deterministic || combinedSem === 0) {- return {- biasRaw,- biasPct,- statType: "deterministic",- passes: deterministicPasses(biasRaw, biasPct, thresholds)- };- }- const z = biasRaw / combinedSem;- return {- biasRaw,- biasPct,- z,- statType: "distribution",- passes: distributionPasses(z, biasPct, thresholds)- };-}--function deterministicPasses(raw: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {- if (pct === undefined) return raw === 0;- const maxDiffRatio = thresholds?.max_diff_ratio_deterministic ?? thresholds?.max_diff_ratio ?? 0.01;- return Math.abs(pct) <= maxDiffRatio * 100;-}--function distributionPasses(z: number, pct: number | undefined, thresholds?: Record<string, number>): boolean {- const minBiasPct = thresholds?.min_bias_pct ?? 0.5;- if (pct !== undefined && Math.abs(pct) < minBiasPct) return true;- const zThreshold = thresholds?.z_threshold ?? 2;- return Math.abs(z) <= zThreshold;-}--function percentDelta(rawDelta: number, expected: number): number | undefined {- if (expected === 0) return rawDelta === 0 ? 0 : undefined;- return (rawDelta / Math.abs(expected)) * 100;-}--function relativeDisplayPath(path: string): string {- const normalized = normalizePath(path);- const cwdRelative = normalizePath(relative(process.cwd(), path));- if (!cwdRelative.startsWith("..")) return cwdRelative;- return normalized;-}-function normalizePath(path: string): string {return path.replaceAll("\\", "/");}Index: simulator/src/classifierDamage.test.ts===================================================================--- simulator/src/classifierDamage.test.ts prev run+++ simulator/src/classifierDamage.test.ts this run@@ -395,12 +395,12 @@source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },duration: { type: "round" as const, value: 1 }};- const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: false });+ const outcome = calculateIndexedDamageJob(job, simpleFighters(), [turnEffect], { trace: true });assert.equal(outcome.consumedEffectIds.includes("bad-luck-like"), false);- assert.ok(outcome.appliedEffectIds.includes("BadLuckStreak/1"));+ assert.ok(outcome.appliedEffectIds?.includes("BadLuckStreak/1"));assert.deepEqual(outcome.appliedEffects, [{effectId: "BadLuckStreak/1",bucket: "active.hero.defense.down",Index: simulator/src/config.test.ts===================================================================--- simulator/src/config.test.ts prev run+++ simulator/src/config.test.ts this run@@ -5,10 +5,9 @@import { tmpdir } from "node:os";import { fileURLToPath } from "node:url";import { loadSimulatorConfigFromDir } from "./config-node";-import { UNIT_TYPES } from "./types";-import type { SkillFile, TriggerDamageJobDefinition } from "./types";+import type { SkillFile } from "./types";test("loadSimulatorConfig warns for non-per-unit turn triggers with trigger-relative effect selectors", () => {const root = writeConfigWithTroopEffect({type: "active.hero.lethality.up",@@ -252,22 +251,8 @@assert.throws(() => loadSimulatorConfigFromDir(omittedRoot), /effect\.applies_vs.*concrete applies_vs/i);assert.throws(() => loadSimulatorConfigFromDir(allRoot), /applies_vs.*all/i);});-function isAllowedTriggerDamageJobSelector(selector: TriggerDamageJobDefinition["source"]): boolean {- const supported = new Set(["use.source", "use.target", "effect.applies_to", "effect.applies_vs", "enemy.living", "self.living"]);- if (typeof selector === "string") return supported.has(selector) || (UNIT_TYPES as string[]).includes(selector);- if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));- return false;-}--function isEffectAppliesVsConcrete(selector: unknown): boolean {- if (selector === "trigger.target" || selector === "target") return true;- if (typeof selector === "string") return (UNIT_TYPES as string[]).includes(selector);- if (Array.isArray(selector)) return selector.length > 0 && selector.every((entry) => typeof entry === "string" && (UNIT_TYPES as string[]).includes(entry));- return false;-}-function writeConfigWithTroopEffect(effect: Record<string, unknown>, trigger: Record<string, unknown> = { type: "turn" }): string {const root = join(tmpdir(), `wos-simulator-config-trigger-jobs-${Date.now()}-${Math.random().toString(16).slice(2)}`);mkdirSync(join(root, "hero_definitions"), { recursive: true });writeFileSync(Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -1,8 +1,7 @@import type {ActiveEffect,DamageAggregationGroupTrace,- AttackOutcome,DamageBucketTrace,DamageEquationTrace,DamageJob,ResolvedFighter,@@ -37,9 +36,8 @@interface BucketCandidate {effect: ActiveEffect;bucket: AtomicBucket;valuePct: number;- carriedInactive?: boolean;}interface MaxBucketCandidateGroup {selected: BucketCandidate;@@ -57,13 +55,25 @@}export type DamageScratch = NumericDamageBuckets;+// Lean result of a single damage job: the correctness-relevant outputs (kills, consumed+// effect bookkeeping) plus, only when tracing, the recording detail. The heavy per-attack+// AttackOutcome is assembled by the recorder, not here, so fast mode allocates nothing extra.+export interface DamageResult {+ kills: number;+ consumedEffectIds: string[];+ consumedEffectUseKey?: string;+ consumedEffectUseId?: string;+ consumedEffectUseIds?: string[];+ appliedEffectIds?: string[];+ appliedEffects?: DamageEquationTrace["appliedEffects"];+ trace?: DamageEquationTrace;+}+const BUCKET_IDS = Object.fromEntries(ATOMIC_BUCKETS.map((bucket, index) => [bucket, index])) as Record<AtomicBucket, NumericBucketId>;const EMPTY_AGGREGATION_GROUPS: Record<string, DamageAggregationGroupTrace> = {};-const EMPTY_APPLIED_EFFECT_IDS: AttackOutcome["appliedEffectIds"] = [];-const EMPTY_COUNTER_DELTAS: AttackOutcome["counterDeltas"] = [];-const EMPTY_CONSUMED_EFFECT_IDS: AttackOutcome["consumedEffectIds"] = [];+const EMPTY_CONSUMED_EFFECT_IDS: string[] = [];const TROOPS_COUNT_TERM = factorTerm("troops.count");const SOURCE_EXTRA_SKILL_TERM = factorTerm("source.extraSkill");const DEFAULT_FACTOR_TERMS = ATOMIC_BUCKETS.map((bucket) => factorTerm(bucket));const DEFAULT_NUMERATOR_TERMS = DEFAULT_FACTOR_TERMS.filter((term) => term.placement === "numerator");@@ -101,13 +111,15 @@export function calculateDamageJob(job: DamageJob,fighters: Record<SideId, ResolvedFighter>,activeEffects: ActiveEffect[],- options: { trace?: boolean; effectIndex: EffectIndex; detail?: DamageDetail; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch }-): AttackOutcome {+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch }+): DamageResult {if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");- const detail = options.detail ?? "full";- const traceEnabled = detail === "full" && options.trace === true;+ // The damage math is one path; `trace` only decides whether we also capture the (expensive)+ // per-bucket contributor/aggregation detail. `detail` drives the existing helpers unchanged.+ const traceEnabled = options.trace === true;+ const detail: DamageDetail = traceEnabled ? "full" : "fast";const staticProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, activeEffects);const attacker = fighters[job.attackerSide];const defender = fighters[job.defenderSide];const attackerTroops = job.roundStartTroops[job.attackerSide][job.attackerUnit] ?? 0;@@ -123,19 +135,15 @@const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];const consumedEffectIds = new Set<string>();const candidates: BucketCandidate[] = [];const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;- const carriedAttackDurationEffectIds = job.carriedAttackDurationEffectIds ? new Set(job.carriedAttackDurationEffectIds) : undefined;for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {- const active = isEffectActive(candidate.effect, job.round);- const carriedInactive = !active && carriedAttackDurationEffectIds?.has(candidate.effect.id) === true;- if (!active && !carriedInactive) continue;+ if (!isEffectActive(candidate.effect, job.round)) continue;handledCandidateEffectIds?.add(candidate.effect.id);candidates.push({effect: candidate.effect,bucket: candidate.bucket,- valuePct: currentEffectValuePct(candidate.effect, job.round),- carriedInactive+ valuePct: currentEffectValuePct(candidate.effect, job.round)});}if (traceEnabled) {@@ -177,34 +185,19 @@finalKills: kills}: undefined;- const fast = detail === "fast";const returnedConsumedEffectIds =- fast && consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];+ consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];return {- jobId: job.id,- kind: job.kind,- sourceEffectId: job.sourceEffectId,- sourceSkillReportKey: job.sourceSkillReportKey,- attackerSide: job.attackerSide,- attackerUnit: job.attackerUnit,- defenderSide: job.defenderSide,- defenderUnit: job.defenderUnit,kills,- counterDeltas: fast- ? EMPTY_COUNTER_DELTAS- : [- { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" },- { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause: job.kind === "skill" ? "extra_skill_attack" : "normal_attack" }- ],- appliedEffectIds: fast ? EMPTY_APPLIED_EFFECT_IDS : appliedEffects.map((effect) => effect.effectId),- appliedEffects,consumedEffectIds: returnedConsumedEffectIds,consumedEffectUseKey: job.consumedEffectUseKey,consumedEffectUseId: job.consumedEffectUseId,consumedEffectUseIds: job.consumedEffectUseIds,+ appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,+ appliedEffects: traceEnabled ? appliedEffects : undefined,trace};}@@ -271,9 +264,9 @@if (selected.effect.stackingKey !== undefined) appliedEffect.stackingKey = selected.effect.stackingKey;appliedEffects.push(appliedEffect);}for (const candidate of candidates) {- if (candidate.effect.duration.type === "attack" && !candidate.carriedInactive) consumedEffectIds.add(candidate.effect.id);+ if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);if (detail === "full" && candidate !== selected) {rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });}}Index: simulator/src/damageBuckets.ts===================================================================--- simulator/src/damageBuckets.ts prev run+++ simulator/src/damageBuckets.ts this run@@ -3,8 +3,13 @@export type BucketRole = "attacker" | "defender";export type BucketValueType = "raw" | "pct";export type BucketUpdate = "assign_factor" | "add_pct_factor";export type BucketPlacement = "numerator" | "denominator";+// "static" pools are closed at compile time (base/player/passive): every contributor is+// known at battle start, so they are aggregated once by the static damage profile.+// "dynamic" pools are open: triggered/timed/conditional effects can feed them per job,+// so they are aggregated in the per-job runtime scratch.+export type BucketPhase = "static" | "dynamic";export interface BucketLeaf {role: BucketRole;valueType: BucketValueType;@@ -18,10 +23,38 @@};export const BUCKETS = {troops: {- count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" }+ count: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },+ baseAttack: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },+ baseLethality: { role: "attacker", valueType: "raw", update: "assign_factor", placement: "numerator" },+ baseHealth: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" },+ baseDefense: { role: "defender", valueType: "raw", update: "assign_factor", placement: "denominator" }},+ player: {+ attack: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ lethality: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ health: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ defense: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ passive: {+ attack: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ lethality: {+ up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },+ down: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "denominator" }+ },+ health: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ },+ defense: {+ up: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "denominator" },+ down: { role: "defender", valueType: "pct", update: "add_pct_factor", placement: "numerator" }+ }+ },active: {hero: {attack: {up: { role: "attacker", valueType: "pct", update: "add_pct_factor", placement: "numerator" },@@ -87,29 +120,43 @@} as const satisfies BucketTree;export interface BucketDefinition extends BucketLeaf {path: string;+ phase: BucketPhase;}+// A pool is closed at compile time (static phase) when no triggered/timed/conditional+// effect can feed it: base troop stats, player stat bonuses, and battle-start passives.+// Everything else is an open pool aggregated per damage job.+const STATIC_BUCKET_FAMILIES = ["troops.base", "player.", "passive."];++function phaseForPath(path: string): BucketPhase {+ return STATIC_BUCKET_FAMILIES.some((family) => path.startsWith(family)) ? "static" : "dynamic";+}+export const BUCKET_DEFINITIONS = flattenBuckets(BUCKETS);-export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS).sort();+// ATOMIC_BUCKETS is the runtime (open-pool) bucket set used by the per-job scratch and+// damage expression. Static-phase buckets are aggregated by the static damage profile and+// never enter the runtime scratch, so they are intentionally excluded here.+export const ATOMIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)+ .filter((path) => BUCKET_DEFINITIONS[path].phase === "dynamic")+ .sort();+export const STATIC_BUCKETS = Object.keys(BUCKET_DEFINITIONS)+ .filter((path) => BUCKET_DEFINITIONS[path].phase === "static")+ .sort();export type AtomicBucket = string;export type BucketName = AtomicBucket;export function bucketDefinition(path: string): BucketDefinition | undefined {return BUCKET_DEFINITIONS[path];}-export function isAtomicBucket(path: string): path is AtomicBucket {- return path in BUCKET_DEFINITIONS;-}-function flattenBuckets(tree: BucketTree, prefix = ""): Record<string, BucketDefinition> {const buckets: Record<string, BucketDefinition> = {};for (const [key, value] of Object.entries(tree)) {const path = prefix ? `${prefix}.${key}` : key;- if (isBucketLeaf(value)) buckets[path] = { ...value, path };+ if (isBucketLeaf(value)) buckets[path] = { ...value, path, phase: phaseForPath(path) };else Object.assign(buckets, flattenBuckets(value, path));}return buckets;}Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -2,11 +2,8 @@import { unitsFromMask } from "./types";import { bucketDefinition, type AtomicBucket } from "./damageBuckets";import { isStaticProfileBucket } from "./staticDamageProfile";-export type DamageEffectKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}:${AtomicBucket}`;-export type DamageJobShapeKey = `${DamageKind}:${SideId}:${UnitType}:${SideId}:${UnitType}`;-export interface IndexedBucketEffect {effect: ActiveEffect;bucket: AtomicBucket;}@@ -46,9 +43,11 @@return;}const definition = bucketDefinition(effect.intent.type);- if (!definition || definition.valueType !== "pct") return;+ // Static-phase buckets (passive.*) are aggregated by the static damage profile, not the+ // per-job runtime path, so they must not enter the damage job-shape index.+ if (!definition || definition.valueType !== "pct" || definition.phase === "static") return;const jobKinds: DamageKind[] = definition.appliesTo ? [definition.appliesTo] : ["normal", "skill"];const appliesToUnits = unitsFromMask(effect.appliesTo.units);const appliesVsUnits = unitsFromMask(effect.appliesVs.units);@@ -85,42 +84,17 @@compactEffects(index.all, (effect) => !isStaticProfileEffect(effect));for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {const candidates = index.damageByJobShape[slot];if (!candidates) continue;- compactCandidates(candidates, (effect, candidate) => !isStaticProfileBucket(candidate.bucket));+ compactCandidates(candidates, (_effect, candidate) => !isStaticProfileBucket(candidate.bucket));if (candidates.length === 0) index.damageByJobShape[slot] = undefined;}}export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {return index.damageByJobShape[damageJobShapeSlot(job.kind, job.attackerSide, job.attackerUnit, job.defenderSide, job.defenderUnit)] ?? [];}-export function bucketEffectsForJob(index: EffectIndex, job: DamageJob): ActiveEffect[] {- return bucketCandidatesForJob(index, job).map((candidate) => candidate.effect);-}--export function damageJobShapeKey(- jobKind: DamageKind,- attackerSide: SideId,- attackerUnit: UnitType,- defenderSide: SideId,- defenderUnit: UnitType-): DamageJobShapeKey {- return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}`;-}--export function damageEffectKey(- jobKind: DamageKind,- attackerSide: SideId,- attackerUnit: UnitType,- defenderSide: SideId,- defenderUnit: UnitType,- bucket: AtomicBucket-): DamageEffectKey {- return `${jobKind}:${attackerSide}:${attackerUnit}:${defenderSide}:${defenderUnit}:${bucket}`;-}-function damageJobShapeSlot(jobKind: DamageKind,attackerSide: SideId,attackerUnit: UnitType,Index: simulator/src/effects.ts===================================================================--- simulator/src/effects.ts prev run+++ simulator/src/effects.ts this run@@ -9,9 +9,9 @@SameEffectStacking,SideId,UnitType} from "./types";-import { ALL_UNIT_MASK, UNIT_TYPES, unitMask } from "./types";+import { ALL_UNIT_MASK, unitMask } from "./types";import { normalizeUnitType } from "./normalize";export type Rng = () => number;Index: simulator/src/index.ts===================================================================--- simulator/src/index.ts prev run+++ simulator/src/index.ts this run@@ -1,7 +1,9 @@export { buildSimulatorConfig, loadSimulatorConfig } from "./config";export type { RawSimulatorConfig } from "./config";-export { simulateBattle } from "./simulator";+export { simulateBattle, signedRemainingScore } from "./simulator";+export { BattleInputBuilder } from "./battleInputBuilder";+export { applyHeroGenerationStats } from "./resolve";export { discoverTestcaseFiles, runTestcases, adaptTestcaseEntry } from "./testcases";export { loadCalibrationComparison, readCalibrationCase, testcaseFileLookupVariants } from "./calibration";export { classifyEffectForJob } from "./classifier";export { calculateDamageJob } from "./damage";Index: simulator/src/recorder.ts===================================================================--- simulator/src/recorder.ts prev run+++ simulator/src/recorder.ts this run@@ -0,0 +1,119 @@+import type {+ AttackIntent,+ AttackOutcome,+ BattleTrace,+ DamageJob,+ SideId,+ SkillReportEntry,+ SimulationMode+} from "./types";+import type { DamageResult } from "./damage";++/**+ * Observes a battle and accumulates whatever the chosen mode needs to report. The battle loop+ * does all simulation-affecting work itself (kills, counters, effect consumption); the recorder+ * only records, so swapping recorders never changes the outcome. This keeps a single, linear+ * loop with no `if (mode === ...)` branches scattered through it.+ *+ * - fast -> NULL_RECORDER: records nothing; zero per-attack allocation.+ * - standard -> RecordingRecorder: per-attack AttackOutcome[] (no equation traces).+ * - trace -> RecordingRecorder + capturesTrace: outcomes with traces + per-round trace.+ */+export interface BattleRecorder {+ /** When true the loop asks calculateDamageJob to capture (expensive) per-bucket trace detail. */+ readonly capturesTrace: boolean;+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void;+ recordDamageJob(job: DamageJob, result: DamageResult): void;+ recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;+ readonly attacks: AttackOutcome[];+ readonly trace: BattleTrace | undefined;+}++const NO_ATTACKS: AttackOutcome[] = [];++export const NULL_RECORDER: BattleRecorder = {+ capturesTrace: false,+ recordCancelled() {},+ recordDamageJob() {},+ recordRound() {},+ attacks: NO_ATTACKS,+ trace: undefined+};++export function createRecorder(+ mode: SimulationMode,+ skillReports: Record<SideId, Map<string, SkillReportEntry>>,+ makeResolved: () => BattleTrace["resolved"]+): BattleRecorder {+ if (mode === "fast") return NULL_RECORDER;+ return new RecordingRecorder(skillReports, mode === "trace" ? { resolved: makeResolved(), rounds: [] } : undefined);+}++class RecordingRecorder implements BattleRecorder {+ readonly attacks: AttackOutcome[] = [];+ readonly trace: BattleTrace | undefined;+ readonly capturesTrace: boolean;++ constructor(+ private readonly skillReports: Record<SideId, Map<string, SkillReportEntry>>,+ trace: BattleTrace | undefined+ ) {+ this.trace = trace;+ this.capturesTrace = trace !== undefined;+ }++ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {+ this.attacks.push({+ jobId: `${intent.id}:cancelled`,+ kind: "normal",+ attackerSide: intent.attackerSide,+ attackerUnit: intent.attackerUnit,+ defenderSide: intent.defenderSide,+ defenderUnit: intent.defenderUnit,+ kills: 0,+ counterDeltas: [+ { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },+ { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }+ ],+ appliedEffectIds: [],+ appliedEffects: [],+ consumedEffectIds,+ cancelledBy: effectId,+ cancelReason: reason+ });+ }++ recordDamageJob(job: DamageJob, result: DamageResult): void {+ if (job.kind === "skill" && job.sourceSkillReportKey && result.kills > 0) {+ const report = this.skillReports[job.attackerSide].get(job.sourceSkillReportKey);+ if (report) report.skillKills += result.kills;+ }+ const cause = job.kind === "skill" ? "extra_skill_attack" : "normal_attack";+ this.attacks.push({+ jobId: job.id,+ kind: job.kind,+ sourceEffectId: job.sourceEffectId,+ sourceSkillReportKey: job.sourceSkillReportKey,+ attackerSide: job.attackerSide,+ attackerUnit: job.attackerUnit,+ defenderSide: job.defenderSide,+ defenderUnit: job.defenderUnit,+ kills: result.kills,+ counterDeltas: [+ { side: job.attackerSide, unit: job.attackerUnit, counter: "attacks", by: 1, cause },+ { side: job.defenderSide, unit: job.defenderUnit, counter: "received_attacks", by: 1, cause }+ ],+ appliedEffectIds: result.appliedEffectIds ?? [],+ appliedEffects: result.appliedEffects ?? [],+ consumedEffectIds: result.consumedEffectIds,+ consumedEffectUseKey: result.consumedEffectUseKey,+ consumedEffectUseId: result.consumedEffectUseId,+ consumedEffectUseIds: result.consumedEffectUseIds,+ trace: result.trace+ });+ }++ recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void {+ this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });+ }+}Index: simulator/src/resolve.ts===================================================================--- simulator/src/resolve.ts prev run+++ simulator/src/resolve.ts this run@@ -1,9 +1,8 @@import type {EffectIntentDefinition,FighterInput,HeroInputCollection,- BattleInput,ResolvedFighter,ResolvedHero,ResolvedSkill,ResolvedTroopLine,@@ -18,9 +17,9 @@import { UNIT_TYPES } from "./types";import { normalizeEngagementType } from "./effects";import { addStats, normalizeStatBlock, normalizeUnitType, valueAtLevel, zeroStats } from "./normalize";-export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, mechanics?: BattleInput["mechanics"]): ResolvedFighter {+export function resolveFighter(input: FighterInput, side: SideId, config: SimulatorConfig, engagementType?: string): ResolvedFighter {const diagnostics: string[] = [];const troops = emptyTroops();const initialTroops = emptyTroops();const troopDetails: Partial<Record<UnitType, ResolvedTroopLine>> = {};@@ -74,11 +73,11 @@};}const statBonuses = resolveInputStatBonuses(input.stats);- const heroes = resolveHeroes(input, side, config, statBonuses, diagnostics, mechanics);- const heroSkills = resolveHeroSkills(input, side, config, diagnostics, mechanics);- const troopSkills = resolveTroopSkills(side, troopDetails, config, mechanics);+ const heroes = resolveHeroes(input, side, config, diagnostics, engagementType);+ const heroSkills = resolveHeroSkills(input, side, config, diagnostics, engagementType);+ const troopSkills = resolveTroopSkills(side, troopDetails, config, engagementType);return {side,name: input.name ?? side,@@ -112,8 +111,27 @@}return byUnit;}+// Build-time scaffolding: fold the summed generation-stat block of all MAIN heroes into+// each unit's stat block, so the simulator can treat FighterInput.stats as authoritative.+// Equivalent to the former in-resolver hero_generation_stats fold, but materialised into the+// input rather than applied during resolution.+export function applyHeroGenerationStats(input: FighterInput, config: SimulatorConfig): FighterInput {+ let bonus = zeroStats();+ for (const instance of heroInputInstances(input)) {+ if (instance.role !== "main") continue;+ const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);+ const definition = resolvedHeroName ? config.heroDefinitions[resolvedHeroName] : undefined;+ if (!definition) continue;+ bonus = addStats(bonus, normalizeStatBlock(config.heroGenerationStats[definition.hero_generation ?? ""] as Record<string, unknown>));+ }+ const byUnit = resolveInputStatBonuses(input.stats);+ const stats: Record<string, StatBlock> = {};+ for (const unit of UNIT_TYPES) stats[unit] = addStats(byUnit[unit], bonus);+ return { ...input, stats };+}+interface HeroInputInstance {name: string;levels: Record<string, number>;role: "main" | "joiner";@@ -146,11 +164,10 @@function resolveHeroes(input: FighterInput,side: SideId,config: SimulatorConfig,- statBonuses: Record<UnitType, StatBlock>,diagnostics: string[],- mechanics?: BattleInput["mechanics"]+ engagementType?: string): ResolvedHero[] {const heroes: ResolvedHero[] = [];for (const instance of heroInputInstances(input)) {const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);@@ -158,27 +175,19 @@if (!definition) {diagnostics.push(`Missing hero definition for ${instance.name}`);heroes.push({name: instance.name,- generationStats: zeroStats(),skillIds: [],instanceId: instance.instanceId,role: instance.role,missing: true});continue;}- const generationStats = normalizeStatBlock(config.heroGenerationStats[definition.hero_generation ?? ""] as Record<string, unknown>);- if (instance.role === "main" && shouldApplyHeroGenerationStats(mechanics)) {- for (const unit of UNIT_TYPES) {- statBonuses[unit] = addStats(statBonuses[unit], generationStats);- }- }heroes.push({name: definition.name ?? instance.name,heroGeneration: definition.hero_generation,- generationStats,- skillIds: resolveHeroSkillIds(definition, instance.levels, side, mechanics),+ skillIds: resolveHeroSkillIds(definition, instance.levels, side, engagementType),instanceId: instance.instanceId,role: instance.role});}@@ -190,9 +199,9 @@input: FighterInput,side: SideId,config: SimulatorConfig,diagnostics: string[],- mechanics?: BattleInput["mechanics"]+ engagementType?: string): ResolvedSkill[] {const skills: ResolvedSkill[] = [];for (const instance of heroInputInstances(input)) {const resolvedHeroName = resolveHeroDefinitionKey(instance.name, config);@@ -202,32 +211,32 @@for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {index += 1;const level = Number(instance.levels[`skill_${index}`] ?? instance.levels[skillId] ?? 0);if (level <= 0) continue;- if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) continue;+ if (!heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) continue;skills.push(hydrateSkill(skillId, rawSkill, side, level, "hero_skill", definition.name ?? resolvedHeroName, undefined, instance.instanceId, instance.role));}}void diagnostics;return skills;}-function resolveHeroSkillIds(definition: SkillFile, levelMap: Record<string, number>, side: SideId, mechanics?: BattleInput["mechanics"]): string[] {+function resolveHeroSkillIds(definition: SkillFile, levelMap: Record<string, number>, side: SideId, engagementType?: string): string[] {const ids: string[] = [];let index = 0;for (const [skillId, rawSkill] of Object.entries(definition.skills ?? {})) {index += 1;const level = Number(levelMap[`skill_${index}`] ?? levelMap[skillId] ?? 0);- if (level > 0 && heroRequirementsSatisfied(rawSkill.requirements, level, side, mechanics)) ids.push(skillId);+ if (level > 0 && heroRequirementsSatisfied(rawSkill.requirements, level, side, engagementType)) ids.push(skillId);}return ids;}function resolveTroopSkills(side: SideId,troopDetails: Partial<Record<UnitType, ResolvedTroopLine>>,config: SimulatorConfig,- mechanics?: BattleInput["mechanics"]+ engagementType?: string): ResolvedSkill[] {const skills: ResolvedSkill[] = [];for (const [skillId, rawSkill] of Object.entries(config.troopSkills.skills ?? {})) {let troopType: UnitType;@@ -243,50 +252,42 @@const satisfied = troopRequirements.filter((req) => (req.type === "tier" ? troop.tier >= Number(req.value) : req.type === "fc" ? troop.fc >= Number(req.value) : false)).sort((a, b) => b.level - a.level)[0];if (!satisfied) continue;- if (!battleRequirementsSatisfied(requirements, satisfied.level, mechanics)) continue;+ if (!battleRequirementsSatisfied(requirements, satisfied.level, engagementType)) continue;skills.push(hydrateSkill(skillId, rawSkill, side, satisfied.level, "troop_skill", undefined, troopType));}return skills;}-function heroRequirementsSatisfied(requirements: SkillRequirement[] | undefined, level: number, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {+function heroRequirementsSatisfied(requirements: SkillRequirement[] | undefined, level: number, side: SideId, engagementType?: string): boolean {return (requirements ?? []).every((requirement) => {- if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, mechanics);+ if (requirement.type !== "engagement_type") return battleRequirementsSatisfied([requirement], level, engagementType);if (level < Number(requirement.level ?? 1)) return true;- return heroEngagementRequirementSatisfied(requirement, side, mechanics);+ return heroEngagementRequirementSatisfied(requirement, side, engagementType);});}-function battleRequirementsSatisfied(requirements: SkillRequirement[], level: number, mechanics?: BattleInput["mechanics"]): boolean {+function battleRequirementsSatisfied(requirements: SkillRequirement[], level: number, engagementType?: string): boolean {return requirements.every((requirement) => {if ((requirement.type === "tier" || requirement.type === "fc") && level < Number(requirement.level ?? 1)) return true;if (requirement.type !== "engagement_type") return true;if (level < Number(requirement.level ?? 1)) return true;- return normalizeEngagementType(requirement.value) === currentEngagementType(mechanics);+ return normalizeEngagementType(requirement.value) === normalizeEngagementType(engagementType);});}-function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, mechanics?: BattleInput["mechanics"]): boolean {+function heroEngagementRequirementSatisfied(requirement: SkillRequirement, side: SideId, engagementType?: string): boolean {const required = normalizeEngagementType(requirement.value);- const current = currentEngagementType(mechanics);+ const current = normalizeEngagementType(engagementType);if (required === current) {if (current !== "rally") return true;return side === "attacker";}if (required === "garrison" && current === "rally") return side === "defender";return false;}-function currentEngagementType(mechanics?: BattleInput["mechanics"]): string | undefined {- return normalizeEngagementType(mechanics?.engagement_type ?? mechanics?.engagementType);-}--function shouldApplyHeroGenerationStats(mechanics?: BattleInput["mechanics"]): boolean {- return mechanics?.hero_generation_stats === true || mechanics?.heroGenerationStats === true;-}-function hydrateSkill(skillId: string,rawSkill: Omit<SkillDefinition, "id" | "name">,side: SideId,Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -4,18 +4,17 @@import { fileURLToPath } from "node:url";import { loadSimulatorConfig } from "./config";import { createSeededRng, chancePasses } from "./effects";-import { resolveFighter } from "./resolve";-import { simulateBattle, simulateBattleScore } from "./simulator";+import { applyHeroGenerationStats, resolveFighter } from "./resolve";+import { simulateBattle, signedRemainingScore } from "./simulator";import type { BattleInput, EffectIntentDefinition, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";test("simulateBattle returns structured result for a no-hero battle", () => {const config = loadSimulatorConfig();const result = simulateBattle({maxRounds: 3,- trace: true,attacker: {name: "A",troops: { infantry_t6: 8000 },stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },@@ -27,9 +26,10 @@stats: { inf: { attack: 10, defense: 10, lethality: 2, health: 2 } },heroes: {}}},- config+ config,+ { mode: "trace" });assert.match(result.winner, /attacker|defender|draw/);assert.ok(result.rounds >= 1);@@ -43,9 +43,8 @@const config = loadSimulatorConfig();const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {name: "A",troops: { infantry_t1: 1 },stats: { inf: { attack: 100000, lethality: 100000 } },@@ -57,9 +56,10 @@stats: { inf: { attack: 100000, lethality: 100000 } },heroes: {}}},- config+ config,+ { mode: "trace" });const normalAttacks = result.attacks.filter((attack) => attack.kind === "normal");assert.equal(normalAttacks.length, 2);@@ -80,9 +80,9 @@const testcases = JSON.parse(readFileSync(fixturePath, "utf8")) as Array<BattleInput & { test_id: string }>;const input = testcases.find((testcase) => testcase.test_id === "simple_001");assert.notEqual(input, undefined);- const result = simulateBattle({ ...input!, trace: true }, config);+ const result = simulateBattle(input!, config, { mode: "trace" });assert.equal(totalRemaining(result.remaining.attacker) - totalRemaining(result.remaining.defender), -186);assert.equal(result.remaining.defender.lancer, 186);assert.equal(result.trace?.rounds[1]?.roundStartTroops.defender.lancer.toFixed(6), "198.003308");@@ -233,9 +233,9 @@assert.equal(fighter.heroes.some((hero) => hero.missing), false);assert.equal(fighter.diagnostics.some((line) => line.includes("Missing hero definition")), false);});-test("hero generation stats are opt-in because testcase stats are authoritative", () => {+test("applyHeroGenerationStats bakes main hero generation stats into authoritative input stats", () => {const config = minimalConfig({Example: {name: "Example",hero_generation: "S1",@@ -249,12 +249,12 @@heroes: { Example: { skill_1: 1 } }};const defaultFighter = resolveFighter(input, "attacker", config);- const optInFighter = resolveFighter(input, "attacker", config, { hero_generation_stats: true });+ const bakedFighter = resolveFighter(applyHeroGenerationStats(input, config), "attacker", config);assert.deepEqual(defaultFighter.statBonuses.infantry, { attack: 1, defense: 2, lethality: 3, health: 4 });- assert.deepEqual(optInFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });+ assert.deepEqual(bakedFighter.statBonuses.infantry, { attack: 51, defense: 42, lethality: 33, health: 24 });});test("array joiner heroes preserve duplicate skill instances", () => {const result = simulateBattle(@@ -323,17 +323,19 @@config.heroGenerationStats.S1 = { attack: 10, defense: 20, lethality: 30, health: 40 };config.heroGenerationStats.S2 = { attack: 100, defense: 200, lethality: 300, health: 400 };const fighter = resolveFighter(- {- troops: { infantry_t1: 10 },- stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },- heroes: [{ name: "Main", levels: {} }],- joiner_heroes: [{ name: "Joiner", levels: { skill_1: 1 } }]- },+ applyHeroGenerationStats(+ {+ troops: { infantry_t1: 10 },+ stats: { inf: { attack: 1, defense: 2, lethality: 3, health: 4 } },+ heroes: [{ name: "Main", levels: {} }],+ joiner_heroes: [{ name: "Joiner", levels: { skill_1: 1 } }]+ },+ config+ ),"attacker",- config,- { hero_generation_stats: true }+ config);assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });});@@ -427,10 +429,10 @@);});test("same_effect_stacking max caps overlapping modifier activations while add stacks them", () => {- const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"));- const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"));+ const maxResult = simulateBattle(sameEffectStackingInput("MaxStacker"), sameEffectStackingConfig("MaxStacker", "max", "active.hero.lethality.up"), { mode: "trace" });+ const addResult = simulateBattle(sameEffectStackingInput("AddStacker"), sameEffectStackingConfig("AddStacker", "add", "active.hero.lethality.up"), { mode: "trace" });const maxRoundTwo = maxResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");const addRoundTwo = addResult.attacks.find((attack) => attack.jobId.startsWith("r2:attacker:infantry") && attack.kind === "normal");@@ -443,9 +445,8 @@test("same_effect_stacking max caps duplicate hero instances of the same skill effect", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { infantry_t1: 100 },heroes: [{ name: "Repeat", levels: { skill_1: 1 } }],joiner_heroes: [{ name: "Repeat", levels: { skill_1: 1 } }]@@ -454,9 +455,10 @@troops: { infantry_t1: 100 },heroes: {}}},- sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up")+ sameEffectStackingConfig("Repeat", "max", "active.hero.lethality.up"),+ { mode: "trace" });const attack = result.attacks.find((entry) => entry.jobId.startsWith("r1:attacker:infantry") && entry.kind === "normal");assert.equal(result.skillReport.attacker.filter((entry) => entry.heroName === "Repeat" && entry.skillId === "Overlap").length, 2);@@ -464,10 +466,10 @@assert.equal(attack?.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);});test("same_effect_stacking max caps overlapping extra skill attacks while add keeps all activations", () => {- const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"));- const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"));+ const maxResult = simulateBattle(sameEffectStackingInput("MaxExtra"), sameEffectStackingConfig("MaxExtra", "max", "extra_skill_attack"), { mode: "trace" });+ const addResult = simulateBattle(sameEffectStackingInput("AddExtra"), sameEffectStackingConfig("AddExtra", "add", "extra_skill_attack"), { mode: "trace" });const maxRoundTwoSkillJobs = maxResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];const addRoundTwoSkillJobs = addResult.trace?.rounds[1]?.jobs.filter((job) => job.kind === "skill") ?? [];@@ -480,9 +482,8 @@test("same_effect_stacking max consumes overlapping attack-duration extra skill effects as one group", () => {const result = simulateBattle({maxRounds: 2,- trace: true,attacker: {troops: { infantry_t1: 10000 },heroes: { MaxExtraAttackDuration: { skill_1: 1 } }},@@ -509,9 +510,10 @@}}}}- })+ }),+ { mode: "trace" });const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));@@ -562,9 +564,8 @@test("fighter passive effects are added to the static profile after battle_start effects", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { infantry_t1: 100 },stats: { infantry: { attack: 0, lethality: 0, defense: 0, health: 0 } },passive: {@@ -592,9 +593,10 @@}}}}- })+ }),+ { mode: "trace" });const attack = result.attacks.find((entry) => entry.attackerSide === "attacker" && entry.attackerUnit === "infantry");assert.equal(attack?.trace?.atomicBuckets["passive.attack.up"].totalPct, 30);@@ -608,9 +610,8 @@test("extra skill attacks with array trigger damage targets hit those defender unit types", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { Router: { skill_1: 1 } }},@@ -643,9 +644,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(@@ -660,9 +662,8 @@test('extra skill attacks with applies_vs "any" keep current-target compatibility', () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { Router: { skill_1: 1 } }},@@ -688,9 +689,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(@@ -702,9 +704,8 @@test("skill report attributes kills only to the skill damage source", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { Shooter: { skill_1: 1 } }},@@ -746,9 +747,10 @@}}}}- })+ }),+ { mode: "trace" });const powerShot = result.skillReport.attacker.find((entry) => entry.skillId === "PowerShot");const crystalShield = result.skillReport.defender.find((entry) => entry.skillId === "CrystalShield");@@ -760,9 +762,8 @@test("same-round outcomes are capped to available target troops before tracing skill kills", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 1000 },heroes: { Blaster: { skill_1: 1 } }},@@ -795,9 +796,10 @@}}}}- })+ }),+ { mode: "trace" });const defenderLosses = result.attacks.filter((attack) => attack.defenderSide === "defender" && attack.defenderUnit === "infantry")@@ -840,11 +842,9 @@if (stats[key] !== undefined) stats[key] = Number((Number(stats[key]) + adjustment).toFixed(3));}}}- adjusted.trace = true;-- const result = simulateBattle(adjusted, config);+ const result = simulateBattle(adjusted, config, { mode: "trace" });const roundSix = result.trace?.rounds.find((round) => round.round === 6);const defenderTargets = roundSix?.intents.filter((intent) => intent.attackerSide === "defender").map((intent) => intent.defenderUnit);@@ -858,9 +858,8 @@() =>simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { Malformed: { skill_1: 1 } }},@@ -886,9 +885,10 @@}}}}- })+ }),+ { mode: "trace" }),/target selector is required/i);});@@ -934,9 +934,8 @@test("attack-triggered extra skill attacks activate then resolve trigger damage jobs on normal attack use", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { FollowUp: { skill_1: 1 } }},@@ -962,9 +961,10 @@}}}}- })+ }),+ { mode: "trace" });const jobs = result.trace?.rounds[0]?.jobs ?? [];const normalJobs = jobs.filter((job) => job.kind === "normal");@@ -981,9 +981,8 @@test("cancelled normal attacks do not consume extra skill attack uses", () => {const result = simulateBattle({maxRounds: 2,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { FollowUp: { skill_1: 1 } }},@@ -1025,9 +1024,10 @@}}}}- })+ }),+ { mode: "trace" });const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");assert.notEqual(cancelled, undefined);@@ -1041,9 +1041,8 @@test("extra skill attack effects cannot be used by later enemy normal attacks", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { FollowUp: { skill_1: 1 } }},@@ -1069,9 +1068,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(@@ -1084,9 +1084,8 @@test("extra skill trigger damage jobs can resolve to multiple living enemy targets without recursive attack triggers", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { MultiTarget: { skill_1: 1, skill_2: 1 } }},@@ -1124,9 +1123,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.deepEqual(@@ -1140,9 +1140,8 @@test("extra skill attack consumes one use regardless of multiple generated target jobs", () => {const result = simulateBattle({maxRounds: 2,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { MultiTarget: { skill_1: 1 } }},@@ -1168,9 +1167,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];assert.deepEqual(skillJobsByRound, [3, 3]);@@ -1218,9 +1218,8 @@test("extra skill attack consumes one use when multiple same-round normal attacks match the effect", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { infantry_t1: 100, marksman_t1: 100 },heroes: { FollowUp: { skill_1: 1 } }},@@ -1246,9 +1245,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobs = result.trace?.rounds[0]?.jobs.filter((job) => job.kind === "skill") ?? [];assert.equal(skillJobs.length, 1);@@ -1258,9 +1258,8 @@test("extra skill attack applies_vs must match the current normal attack target", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { FollowUp: { skill_1: 1 } }},@@ -1286,9 +1285,10 @@}}}}- })+ }),+ { mode: "trace" });const jobs = result.trace?.rounds[0]?.jobs ?? [];assert.deepEqual(@@ -1301,9 +1301,8 @@test("extra skill de-dupe does not suppress unrelated attack-duration effect consumption", () => {const result = simulateBattle({maxRounds: 2,- trace: true,attacker: {troops: { marksman_t1: 100 },heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }},@@ -1340,9 +1339,10 @@}}}}- })+ }),+ { mode: "trace" });const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];const skillBoosts = result.attacks@@ -1352,94 +1352,12 @@assert.deepEqual(skillBoosts, [100, 100, 0, 0]);assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitLiving: 4 });});-test("attack-duration normal damage effects do not carry to triggered extra skill damage by default", () => {- const result = simulateBattle(- {- maxRounds: 1,- trace: true,- attacker: {- troops: { marksman_t1: 100 },- heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }- },- defender: {- troops: { infantry_t1: 100 },- heroes: {}- }- },- attackDurationCarryConfig()- );-- const normalBoosts = result.attacks- .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);- const skillBoosts = result.attacks- .filter((attack) => attack.kind === "skill")- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);- assert.deepEqual(normalBoosts, [100]);- assert.deepEqual(skillBoosts, [0]);-});--test("mechanics flag carries consumed attack-duration effects to matching triggered extra skill damage only", () => {- const result = simulateBattle(- {- maxRounds: 1,- trace: true,- mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },- attacker: {- troops: { marksman_t1: 100 },- heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }- },- defender: {- troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },- heroes: {}- }- },- attackDurationCarryConfig({ buffAppliesVs: "infantry", extraTargets: "enemy.living" })- );-- const normalBoosts = result.attacks- .filter((attack) => attack.kind === "normal" && attack.attackerSide === "attacker")- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0);- const skillBoostsByTarget = result.attacks- .filter((attack) => attack.kind === "skill")- .map((attack) => `${attack.defenderUnit}:${attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0}`)- .sort();- assert.deepEqual(normalBoosts, [100]);- assert.deepEqual(skillBoostsByTarget, ["infantry:100", "lancer:0", "marksman:0"]);-});--test("mechanics flag carries consumed attack-duration effects with any target scope to all matching triggered extra skill damage", () => {- const result = simulateBattle(- {- maxRounds: 1,- trace: true,- mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true },- attacker: {- troops: { marksman_t1: 100 },- heroes: { FollowUp: { skill_1: 1, skill_2: 1 } }- },- defender: {- troops: { infantry_t1: 100, lancer_t1: 100, marksman_t1: 100 },- heroes: {}- }- },- attackDurationCarryConfig({ buffAppliesVs: "any", extraTargets: "enemy.living" })- );-- const skillBoosts = result.attacks- .filter((attack) => attack.kind === "skill")- .map((attack) => attack.trace?.atomicBuckets["active.hero.attack.up"].totalPct ?? 0)- .sort((left, right) => left - right);- assert.deepEqual(skillBoosts, [100, 100, 100]);-});-test("attack-triggered source and target selectors resolve to concrete active scopes", () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { infantry_t1: 100 },heroes: { Debuffer: { skill_1: 1 } }},@@ -1464,9 +1382,10 @@}}}}- })+ }),+ { mode: "trace" });const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");@@ -1480,9 +1399,8 @@test('attack-triggered target selector with applies_vs "any" gates later opposing attacks', () => {const result = simulateBattle({maxRounds: 1,- trace: true,attacker: {troops: { infantry_t1: 100 },heroes: { Debuffer: { skill_1: 1 } }},@@ -1507,9 +1425,10 @@}}}}- })+ }),+ { mode: "trace" });const attackerAttack = result.attacks.find((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "infantry");const defenderLancerAttack = result.attacks.find((attack) => attack.attackerSide === "defender" && attack.attackerUnit === "lancer");@@ -1550,10 +1469,10 @@}};const defaultResult = simulateBattle(input, config);- const rallyResult = simulateBattle({ ...input, mechanics: { engagement_type: "rally" } }, config);- const garrisonResult = simulateBattle({ ...input, mechanics: { engagement_type: "garrison" } }, config);+ const rallyResult = simulateBattle({ ...input, engagement_type: "rally" }, config);+ const garrisonResult = simulateBattle({ ...input, engagement_type: "garrison" }, config);assert.equal(skillActivations(defaultResult, "RallyOnly"), 0);assert.equal(skillActivations(defaultResult, "GarrisonOnly"), 0);assert.equal(defaultResult.skillReport.attacker.some((entry) => entry.skillId === "RallyOnly"), false);@@ -1585,9 +1504,9 @@}});const input = {maxRounds: 0,- mechanics: { engagement_type: "rally" },+ engagement_type: "rally",attacker: {troops: { infantry_t1: 10 },heroes: { Gated: { skill_1: 1, skill_2: 1 } }},@@ -1745,9 +1664,9 @@name: "stochastic real hero battle",input: {maxRounds: 8,seed: "fast-real-heroes",- mechanics: { hero_generation_stats: true, engagement_type: "rally" },+ engagement_type: "rally",attacker: {troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]@@ -1762,23 +1681,23 @@}];for (const { name, input, config } of cases) {- const full = simulateBattle({ ...input, trace: true }, config);- const fast = simulateBattle({ ...input, trace: true }, config, { detail: "fast" });+ const full = simulateBattle(input, config);+ const fast = simulateBattle(input, config, { mode: "fast" });assert.deepEqual(semanticBattleSummary(fast), semanticBattleSummary(full), name);assert.deepEqual(fast.attacks, [], name);assert.equal(fast.trace, undefined, name);}});-test("simulateBattleScore returns signed remaining troops from the same battle semantics", () => {+test("signedRemainingScore returns signed remaining troops from a fast-mode result", () => {const config = loadSimulatorConfig();const input: BattleInput = {maxRounds: 8,seed: "score-real-heroes",- mechanics: { hero_generation_stats: true, engagement_type: "rally" },+ engagement_type: "rally",attacker: {troops: { infantry_t10: 500, lancer_t10: 200, marksman_t10: 300 },heroes: { "Wu Ming": { skill_1: 5, skill_2: 5, skill_3: 5 }, Mia: { skill_1: 5, skill_2: 5, skill_3: 5 } },joiner_heroes: [{ name: "Jessie", levels: { skill_1: 5 } }]@@ -1789,12 +1708,19 @@joiner_heroes: [{ name: "Norah", levels: { skill_1: 5 } }]}};- const result = simulateBattle(input, config, { detail: "fast" });- const score = simulateBattleScore(input, config);+ const result = simulateBattle(input, config, { mode: "fast" });+ const score = signedRemainingScore(result);- assert.equal(score, signedRemainingScore(result));+ const expected =+ result.winner === "attacker"+ ? totalRemaining(result.remaining.attacker)+ : result.winner === "defender"+ ? -totalRemaining(result.remaining.defender)+ : 0;++ assert.equal(score, expected);});test("seeded probability rolls are deterministic and compare percentage thresholds", () => {const skill: ResolvedSkill = {@@ -1832,14 +1758,8 @@function totalRemaining(troops: Record<UnitType, number>): number {return Object.values(troops).reduce((sum, count) => sum + count, 0);}-function signedRemainingScore(result: ReturnType<typeof simulateBattle>): number {- if (result.winner === "attacker") return totalRemaining(result.remaining.attacker);- if (result.winner === "defender") return -totalRemaining(result.remaining.defender);- return 0;-}-function semanticBattleSummary(result: ReturnType<typeof simulateBattle>): Pick<ReturnType<typeof simulateBattle>,"winner" | "rounds" | "remaining" | "effectActivationCounts" | "extraSkillAttackJobsByEffect" | "attackControlCounts"> {@@ -1855,9 +1775,8 @@function sameEffectStackingInput(heroName: string): BattleInput {return {maxRounds: 2,- trace: true,attacker: {troops: { infantry_t1: 10000 },heroes: { [heroName]: { skill_1: 1 } }},@@ -1912,40 +1831,4 @@diagnostics: { legacyFields: [], effectTypes: {}, unsupportedEffects: [], ambiguousTurnTriggerSelectors: [] }};}-function attackDurationCarryConfig(- options: { buffAppliesVs?: "any" | "infantry"; extraTargets?: "use.target" | "enemy.living" } = {}-): SimulatorConfig {- const buffAppliesVs = options.buffAppliesVs ?? "any";- const extraTargets = options.extraTargets ?? "use.target";- return minimalConfig({- FollowUp: {- name: "FollowUp",- skills: {- OneUseNormalBoost: {- trigger: { type: "battle_start" },- effects: {- boost: {- type: "active.hero.attack.up",- value: 100,- units: { applies_to: "marksman", applies_vs: buffAppliesVs },- duration: { type: "attack", value: 1 }- }- }- },- TriggeredExtraDamage: {- trigger: { type: "attack", probability: 100, source: "marksman" },- effects: {- extra: {- type: "extra_skill_attack",- value: 100,- units: { applies_to: "trigger.source", applies_vs: "any" },- trigger_damage_jobs: [{ source: "use.source", target: extraTargets }],- duration: { type: "attack", value: 1 }- }- }- }- }- }- });-}Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -18,9 +18,10 @@TriggerDamageJobSelector,UnitType} from "./types";import { ALL_UNIT_MASK, UNIT_TYPES, unitMaskHas, unitsFromMask } from "./types";-import { calculateDamageJob, createFastDamageScratch, type DamageScratch } from "./damage";+import { calculateDamageJob, createFastDamageScratch, type DamageResult, type DamageScratch } from "./damage";+import { createRecorder } from "./recorder";import {activateEffect,chancePasses,createSeededRng,@@ -52,9 +53,8 @@effectActivationCounts: Record<SideId, number>;extraSkillAttackJobsByEffect: Record<string, number>;attackControlCounts: { dodge: number; no_attack: number };consumedEffectUseKeys: Set<string>;- carryAttackDurationEffectsToTriggeredExtraSkillDamage: boolean;counters: {attacks: Record<SideId, Record<UnitType, number>>;received: Record<SideId, Record<UnitType, number>>;};@@ -103,22 +103,15 @@trace: run.trace};}-export function simulateBattleScore(input: BattleInput, config: SimulatorConfig): number {- const run = runBattle(input, config, { detail: "fast" });- return signedRemainingScore(run.winner, run.fighters);-}-function runBattle(input: BattleInput, config: SimulatorConfig, options: SimulationOptions): BattleRun {- const attacker = resolveFighter(input.attacker, "attacker", config, input.mechanics);- const defender = resolveFighter(input.defender, "defender", config, input.mechanics);+ const attacker = resolveFighter(input.attacker, "attacker", config, input.engagement_type);+ const defender = resolveFighter(input.defender, "defender", config, input.engagement_type);const fighters: Record<SideId, ResolvedFighter> = { attacker, defender };- const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"), input.mechanics);- const detail = options.detail ?? "full";- const traceEnabled = detail === "full" && input.trace;- const trace: BattleTrace | undefined = traceEnabled ? { resolved: buildResolved(attacker, defender), rounds: [] } : undefined;- const attacks: AttackOutcome[] = [];+ const runtime = createRuntime([attacker, defender], createSeededRng(input.seed ?? "simulator-default"));+ const mode = options.mode ?? "standard";+ const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(attacker, defender));const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;triggerSkills("battle_start", 0, runtime.skills.battleStart, runtime);addInputPassiveEffects(runtime, input.attacker.passive, "attacker");@@ -131,69 +124,75 @@if (winnerFor(fighters)) break;rounds = round;const roundStartTroops = snapshotTroops(fighters);expireInactive(runtime, round);- triggerRoundStartSkills(round, fighters, runtime, roundStartTroops);+ triggerRoundStartSkills(round, runtime, roundStartTroops);- const intents = resolveAttackIntents(round, fighters, runtime, roundStartTroops);+ const intents = resolveAttackIntents(round, runtime, roundStartTroops);const jobs: DamageJob[] = [];- const cancelled: AttackOutcome[] = [];+ const cancelled: CancelledAttack[] = [];for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);const controls = applicableControls(job, round, runtime);if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;runtime.attackControlCounts[control.reason] += 1;- const outcome = cancelledOutcome(intent, control.effect.id, control.reason, attackDurationEffectIdsForJob(job, round, runtime.activeEffects));- cancelled.push(outcome);- consumeEffects(runtime, outcome.consumedEffectIds);+ const consumedEffectIds = attackDurationEffectIdsForJob(job, round, runtime.activeEffects);+ cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });+ consumeEffects(runtime, consumedEffectIds);} else {jobs.push(job);jobs.push(...extraSkillJobs(job, round, runtime, roundStartTroops));}}- const roundOutcomes: AttackOutcome[] = [];+ const results: DamageJobResult[] = [];for (const job of jobs) {- const outcome =- detail === "fast"- ? calculateDamageJob(job, fighters, runtime.activeEffects, {- detail: "fast",- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile,- scratch: runtime.damageScratch- })- : calculateDamageJob(job, fighters, runtime.activeEffects, {- trace: traceEnabled,- effectIndex: runtime.effectIndex,- staticDamageProfile: runtime.staticDamageProfile- });- roundOutcomes.push(outcome);- consumeEffects(runtime, outcome.consumedEffectIds, outcome.consumedEffectUseKey, outcome.consumedEffectUseId, outcome.consumedEffectUseIds);+ const result = calculateDamageJob(job, fighters, runtime.activeEffects, {+ trace: recorder.capturesTrace,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch+ });+ results.push({ job, result });+ consumeEffects(runtime, result.consumedEffectIds, result.consumedEffectUseKey, result.consumedEffectUseId, result.consumedEffectUseIds);}- finalizeRoundOutcomes(roundOutcomes, roundStartTroops, runtime);- if (detail === "full") attacks.push(...cancelled, ...roundOutcomes);- commitOutcomes(cancelled, fighters, runtime);- commitOutcomes(roundOutcomes, fighters, runtime);- trace?.rounds.push({ round, roundStartTroops, intents, jobs });++ capRoundKills(results, roundStartTroops);+ commitRound(cancelled, results, fighters, runtime);++ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.consumedEffectIds);+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result);+ recorder.recordRound(round, roundStartTroops, intents, jobs);}const winner = winnerFor(fighters) ?? "draw";return {fighters,runtime,winner,rounds,- attacks,- trace+ attacks: recorder.attacks,+ trace: recorder.trace};}+interface CancelledAttack {+ intent: AttackIntent;+ effectId: string;+ reason: "dodge" | "no_attack";+ consumedEffectIds: string[];+}++interface DamageJobResult {+ job: DamageJob;+ result: DamageResult;+}+function triggerRoundStartSkills(round: number,- fighters: Record<SideId, ResolvedFighter>,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"]): ActiveEffect[] {const activated: ActiveEffect[] = [];@@ -289,9 +288,9 @@const value = Array.isArray(probability) ? Number(probability[Math.max(0, Math.min(probability.length - 1, skill.level - 1))]) : Number(probability);return Number.isFinite(value) && value > 0 && value < 100;}-function createRuntime(fighters: ResolvedFighter[], rng: Rng, mechanics?: BattleInput["mechanics"]): Runtime {+function createRuntime(fighters: ResolvedFighter[], rng: Rng): Runtime {const reports: Record<SideId, Map<string, SkillReportEntry>> = { attacker: new Map(), defender: new Map() };const skills = buildRuntimeSkills(fighters);for (const fighter of fighters) {for (const skill of [...(fighter.heroSkills ?? []), ...fighter.troopSkills]) {@@ -321,9 +320,8 @@effectActivationCounts: { attacker: 0, defender: 0 },extraSkillAttackJobsByEffect: {},attackControlCounts: { dodge: 0, no_attack: 0 },consumedEffectUseKeys: new Set(),- carryAttackDurationEffectsToTriggeredExtraSkillDamage: mechanics?.carryAttackDurationEffectsToTriggeredExtraSkillDamage === true,counters: {attacks: { attacker: emptyTroops(), defender: emptyTroops() },received: { attacker: emptyTroops(), defender: emptyTroops() }}@@ -410,9 +408,8 @@}function resolveAttackIntents(round: number,- fighters: Record<SideId, ResolvedFighter>,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"]): AttackIntent[] {const intents: AttackIntent[] = [];@@ -514,11 +511,8 @@runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"]): DamageJob[] {const jobs: DamageJob[] = [];- const carriedAttackDurationEffectIds = runtime.carryAttackDurationEffectsToTriggeredExtraSkillDamage- ? attackDurationBucketEffectIdsForJob(normalAttack, round, runtime.activeEffects)- : undefined;const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round);@@ -552,9 +546,8 @@defenderUnit: target.unit,sourceEffectId,sourceSkillReportKey,sourceMultiplier: multiplier,- carriedAttackDurationEffectIds,consumedEffectIds,consumedEffectUseKey,consumedEffectUseId: effect.id,consumedEffectUseIds: consumedEffectIds@@ -654,77 +647,46 @@const pct = Number(raw ?? 0);return Number.isFinite(pct) ? pct / 100 : 0;}-function cancelledOutcome(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[] = [effectId]): AttackOutcome {- return {- jobId: `${intent.id}:cancelled`,- kind: "normal",- attackerSide: intent.attackerSide,- attackerUnit: intent.attackerUnit,- defenderSide: intent.defenderSide,- defenderUnit: intent.defenderUnit,- kills: 0,- counterDeltas: [- { side: intent.attackerSide, unit: intent.attackerUnit, counter: "attacks", by: 1, cause: "normal_attack" },- { side: intent.defenderSide, unit: intent.defenderUnit, counter: "received_attacks", by: 1, cause: "normal_attack" }- ],- appliedEffectIds: [],- appliedEffects: [],- consumedEffectIds,- cancelledBy: effectId,- cancelReason: reason- };-}--function finalizeRoundOutcomes(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"], runtime: Runtime): void {- capRoundOutcomeKills(outcomes, roundStartTroops);- attributeSkillKills(outcomes, runtime);-}--function capRoundOutcomeKills(outcomes: AttackOutcome[], roundStartTroops: DamageJob["roundStartTroops"]): void {+// Cap each defender unit's total kills this round to the troops available at round start, applied+// in job order. Mutates result.kills (and the trace's finalKills when present). This is+// simulation-affecting and runs in every mode, before commit and recording.+function capRoundKills(results: DamageJobResult[], roundStartTroops: DamageJob["roundStartTroops"]): void {for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {- const matching = outcomes.filter((outcome) => outcome.defenderSide === side && outcome.defenderUnit === unit && outcome.kills > 0);+ const matching = results.filter((entry) => entry.job.defenderSide === side && entry.job.defenderUnit === unit && entry.result.kills > 0);if (matching.length === 0) continue;const available = Math.max(0, roundStartTroops[side][unit] ?? 0);- const totalKills = matching.reduce((sum, outcome) => sum + outcome.kills, 0);+ const totalKills = matching.reduce((sum, entry) => sum + entry.result.kills, 0);if (totalKills <= available) continue;let appliedKills = 0;let rawRemaining = available;- for (const outcome of matching) {- const rawKills = outcome.kills;- outcome.kills = Math.min(rawKills, Math.max(0, available - appliedKills));- appliedKills += outcome.kills;+ for (const entry of matching) {+ const rawKills = entry.result.kills;+ entry.result.kills = Math.min(rawKills, Math.max(0, available - appliedKills));+ appliedKills += entry.result.kills;rawRemaining = Math.max(0, rawRemaining - rawKills);if (rawRemaining === 0) appliedKills = available;- if (outcome.trace) outcome.trace.finalKills = outcome.kills;+ if (entry.result.trace) entry.result.trace.finalKills = entry.result.kills;}}}}-function attributeSkillKills(outcomes: AttackOutcome[], runtime: Runtime): void {- for (const outcome of outcomes) {- if (outcome.kind !== "skill" || !outcome.sourceSkillReportKey || outcome.kills <= 0) continue;- const report = runtime.skillReports[outcome.attackerSide].get(outcome.sourceSkillReportKey);- if (report) report.skillKills += outcome.kills;+// Apply the round's effects to fighter state: remove killed troops and bump attack/received+// counters. Every declared attack (each damage job and each cancelled attack) counts as one+// attack for its attacker unit and one received for its defender unit.+function commitRound(cancelled: CancelledAttack[], results: DamageJobResult[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {+ for (const entry of cancelled) {+ runtime.counters.attacks[entry.intent.attackerSide][entry.intent.attackerUnit] += 1;+ runtime.counters.received[entry.intent.defenderSide][entry.intent.defenderUnit] += 1;}-}--function commitOutcomes(outcomes: AttackOutcome[], fighters: Record<SideId, ResolvedFighter>, runtime: Runtime): void {const losses: Record<SideId, Record<UnitType, number>> = { attacker: emptyTroops(), defender: emptyTroops() };- for (const outcome of outcomes) {- losses[outcome.defenderSide][outcome.defenderUnit] += outcome.kills;- if (outcome.counterDeltas.length === 0) {- runtime.counters.attacks[outcome.attackerSide][outcome.attackerUnit] += 1;- runtime.counters.received[outcome.defenderSide][outcome.defenderUnit] += 1;- } else {- for (const delta of outcome.counterDeltas) {- if (delta.counter === "attacks") runtime.counters.attacks[delta.side][delta.unit] += delta.by;- else runtime.counters.received[delta.side][delta.unit] += delta.by;- }- }+ for (const { job, result } of results) {+ losses[job.defenderSide][job.defenderUnit] += result.kills;+ runtime.counters.attacks[job.attackerSide][job.attackerUnit] += 1;+ runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;}for (const side of ["attacker", "defender"] as SideId[]) {for (const unit of UNIT_TYPES) {fighters[side].troops[unit] = Math.max(0, fighters[side].troops[unit] - losses[side][unit]);@@ -754,18 +716,8 @@}).map((effect) => effect.id);}-function attackDurationBucketEffectIdsForJob(job: DamageJob, round: number, effects: ActiveEffect[]): string[] {- return effects- .filter((effect) => {- if (effect.kind === "extra_attack" || effect.duration.type !== "attack" || !isEffectActive(effect, round)) return false;- const classification = classifyEffectForJob(effect, job);- return classification?.kind === "bucket";- })- .map((effect) => effect.id);-}-function consumeEffects(runtime: Runtime,consumedEffectIds: string[],consumedEffectUseKey?: string,@@ -801,11 +753,13 @@if (defenderAlive && !attackerAlive) return "defender";return undefined;}-function signedRemainingScore(winner: SideId | "draw", fighters: Record<SideId, ResolvedFighter>): number {- if (winner === "attacker") return total(ceilTroops(fighters.attacker.troops));- if (winner === "defender") return -total(ceilTroops(fighters.defender.troops));+// Signed battle outcome: positive = attacker survivors, negative = defender survivors, 0 = draw.+// Replaces the former simulateBattleScore entry point; call with a "fast"-mode result.+export function signedRemainingScore(result: BattleResult): number {+ if (result.winner === "attacker") return total(result.remaining.attacker);+ if (result.winner === "defender") return -total(result.remaining.defender);return 0;}function total(troops: Record<UnitType, number>): number {Index: simulator/src/staticDamageProfile.ts===================================================================--- simulator/src/staticDamageProfile.ts prev run+++ simulator/src/staticDamageProfile.ts this run@@ -1,7 +1,7 @@import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";import { UNIT_TYPES, unitMaskHas } from "./types";-import type { BucketRole } from "./damageBuckets";+import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";import { currentEffectValuePct } from "./effects";export interface StaticDamageProfileTerm {raw?: number;@@ -48,18 +48,12 @@export type StaticPlayerBucket = (typeof STATIC_PLAYER_BUCKETS)[number];export type StaticPassiveBucket = (typeof STATIC_PASSIVE_BUCKETS)[number];export type StaticDamageBucket = StaticRawBucket | StaticPlayerBucket | StaticPassiveBucket;-const STATIC_PASSIVE_BUCKET_ROLES: Record<StaticPassiveBucket, BucketRole> = {- "passive.attack.up": "attacker",- "passive.attack.down": "attacker",- "passive.lethality.up": "attacker",- "passive.lethality.down": "attacker",- "passive.health.up": "defender",- "passive.health.down": "defender",- "passive.defense.up": "defender",- "passive.defense.down": "defender"-};+// Roles are owned by the BUCKETS tree (single source of truth); derive, do not redeclare.+const STATIC_PASSIVE_BUCKET_ROLES = Object.fromEntries(+ STATIC_PASSIVE_BUCKETS.map((bucket) => [bucket, bucketDefinition(bucket)!.role])+) as Record<StaticPassiveBucket, BucketRole>;export function buildStaticDamageProfile(fighters: Record<SideId, ResolvedFighter>, activeEffects: ActiveEffect[]): StaticDamageProfile {const profile: StaticDamageProfile = {offense: {@@ -192,37 +186,53 @@}}}+interface StaticFactorTerm {+ bucket: StaticDamageBucket;+ valueType: BucketValueType;+ placement: BucketPlacement;+}++// The closed-pool aggregation is driven entirely by BUCKETS-tree metadata (role/valueType/+// placement); there is no second hand-written damage equation. Iteration follows tree+// insertion order, which fixes the numerator/denominator float association — do not reorder+// the static families in the tree without re-validating the parity gate.+const STATIC_FACTOR_TERMS_BY_ROLE: Record<BucketRole, StaticFactorTerm[]> = { attacker: [], defender: [] };+for (const path of Object.keys(BUCKET_DEFINITIONS)) {+ const definition = BUCKET_DEFINITIONS[path];+ if (definition.phase !== "static") continue;+ STATIC_FACTOR_TERMS_BY_ROLE[definition.role].push({+ bucket: path as StaticDamageBucket,+ valueType: definition.valueType,+ placement: definition.placement+ });+}++function roleFactor(entry: StaticDamageProfileEntry, terms: StaticFactorTerm[]): number {+ let numerator = 1;+ let denominator = 1;+ for (const term of terms) {+ const value = term.valueType === "raw" ? raw(entry, term.bucket) : pct(entry, term.bucket);+ if (term.placement === "numerator") numerator *= value;+ else denominator *= value;+ }+ return numerator / denominator;+}+function offenseFactor(entry: StaticDamageProfileEntry): number {- return (- raw(entry, "troops.baseAttack") *- raw(entry, "troops.baseLethality") *- pct(entry, "player.attack") *- pct(entry, "player.lethality") *- pct(entry, "passive.attack.up") *- pct(entry, "passive.lethality.up") /- (pct(entry, "passive.attack.down") * pct(entry, "passive.lethality.down"))- );+ return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.attacker);}function defenseFactor(entry: StaticDamageProfileEntry): number {- return (- (pct(entry, "passive.health.down") * pct(entry, "passive.defense.down")) /- (raw(entry, "troops.baseHealth") *- raw(entry, "troops.baseDefense") *- pct(entry, "player.health") *- pct(entry, "player.defense") *- pct(entry, "passive.health.up") *- pct(entry, "passive.defense.up"))- );+ return roleFactor(entry, STATIC_FACTOR_TERMS_BY_ROLE.defender);}-function raw(entry: StaticDamageProfileEntry, bucket: StaticRawBucket): number {+function raw(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {return Math.max(0, entry.buckets[bucket]?.raw ?? 0);}-function pct(entry: StaticDamageProfileEntry, bucket: StaticPlayerBucket | StaticPassiveBucket): number {+function pct(entry: StaticDamageProfileEntry, bucket: StaticDamageBucket): number {return 1 + (entry.buckets[bucket]?.totalPct ?? 0) / 100;}function setRaw(buckets: StaticDamageProfileEntry["buckets"], bucket: StaticRawBucket, raw: number): void {@@ -261,4 +271,14 @@}const STATIC_BUCKET_SET = new Set<string>([...STATIC_RAW_BUCKETS, ...STATIC_PLAYER_BUCKETS, ...STATIC_PASSIVE_BUCKETS]);const STATIC_PASSIVE_BUCKET_SET = new Set<string>(STATIC_PASSIVE_BUCKETS);++// The typed manifest above is a convenience for literal types; the BUCKETS tree is the+// authority for which buckets are static. Fail loudly if the two ever drift.+{+ const manifest = [...STATIC_BUCKET_SET].sort().join(",");+ const tree = [...STATIC_BUCKETS].sort().join(",");+ if (manifest !== tree) {+ throw new Error(`static bucket manifest drifted from BUCKETS tree:\n manifest=[${manifest}]\n tree=[${tree}]`);+ }+}Index: simulator/src/testcases.test.ts===================================================================--- simulator/src/testcases.test.ts prev run+++ simulator/src/testcases.test.ts this run@@ -236,8 +236,17 @@assert.equal(summary?.gameStatAdjustment?.value, 0.05);assert.equal(summary?.gameStatAdjustment?.unadjusted.bias_raw, -2);});+test("runTestcases skips stat rounding correction for stochastic misses", () => {+ const config = loadSimulatorConfig();+ const report = runTestcases({ matching: "greg_mia_combo", repeat: 5, calibrationReportPath: "/tmp/does-not-exist.json" }, config);+ const summaries = Object.values(report.testcases);++ assert.ok(summaries.some((summary) => summary.deterministic === false && summary.game?.passes === false));+ assert.deepEqual(summaries.map((summary) => summary.gameStatAdjustment), summaries.map(() => undefined));+});+test("runTestcases default round cap lets long no-hero baselines reach battle end", () => {const config = loadSimulatorConfig();const report = runTestcases({ matching: "1-testcases_no-heroes_t6_single-type_nc.json", repeat: 1 }, config);const entry = report.details.find((item) => item.testcaseId === "daut_viper_9");@@ -272,37 +281,28 @@assert.equal(report.counts.comparedToGame, 1);assert.equal(report.counts.comparedToBaseline, 1);});-test("adaptTestcaseEntry passes testcase mechanics and engagement aliases into BattleInput", () => {+test("adaptTestcaseEntry promotes engagement_type to a top-level BattleInput key", () => {const input = adaptTestcaseEntry({- test_id: "mechanics_case",+ test_id: "engagement_case",engagement_type: "rally",- mechanics: { weather: "clear" },attacker: { troops: { infantry_t1: 1 } },defender: { troops: { infantry_t1: 1 } }});- assert.deepEqual(input.mechanics, { weather: "clear", engagement_type: "rally" });+ assert.equal(input.engagement_type, "rally");});-test("adaptTestcaseEntry merges option mechanics without replacing testcase mechanics", () => {- const input = adaptTestcaseEntry(- {- test_id: "mechanics_case",- engagement_type: "rally",- mechanics: { weather: "clear" },- attacker: { troops: { infantry_t1: 1 } },- defender: { troops: { infantry_t1: 1 } }- },- { mechanics: { carryAttackDurationEffectsToTriggeredExtraSkillDamage: true } }- );-- assert.deepEqual(input.mechanics, {- weather: "clear",- carryAttackDurationEffectsToTriggeredExtraSkillDamage: true,- engagement_type: "rally"+test("adaptTestcaseEntry reads engagement_type nested under a legacy mechanics object", () => {+ const input = adaptTestcaseEntry({+ test_id: "nested_engagement_case",+ mechanics: { engagement_type: "garrison" },+ attacker: { troops: { infantry_t1: 1 } },+ defender: { troops: { infantry_t1: 1 } }});++ assert.equal(input.engagement_type, "garrison");});test("compareOutcomeDistribution matches deterministic zero-bias shape", () => {const metrics = compareOutcomeDistribution({Index: simulator/src/testcases.ts===================================================================--- simulator/src/testcases.ts prev run+++ simulator/src/testcases.ts this run@@ -16,19 +16,18 @@const DEFAULT_STOCHASTIC_REPEAT = 100;const STAT_ROUNDING_MAX_ADJUSTMENT = 0.05;const STAT_ROUNDING_SCAN_STEPS = 50;+const STAT_ROUNDING_INTERPOLATION_LIMIT = STAT_ROUNDING_SCAN_STEPS;export interface TestcaseRunOptions {testcaseRoot?: string;calibrationReportPath?: string;matching?: string;includeDisabled?: boolean;repeat?: number;seed?: string | number;- trace?: boolean;workers?: number;- mechanics?: Record<string, unknown>;}export interface TestcaseCaseReport {file: string;@@ -207,9 +206,9 @@const diagnostics: string[] = [];const detail = emptyCaseReport(reportFile, testcaseId, index, diagnostics);const preparedCase: PreparedTestcaseCase = { file, reportFile, entry, testcaseId, index, detail };try {- preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed, trace: options.trace, mechanics: options.mechanics }, diagnostics);+ preparedCase.input = adaptTestcaseEntry(entry, { seed: options.seed }, diagnostics);preparedCase.key = snapshotKey(reportFile, index);} catch (error) {detail.error = errorMessage(error);diagnostics.push(detail.error);@@ -242,9 +241,9 @@details: []};for (const preparedCase of prepared.cases) {- const { file, reportFile, entry, testcaseId, index, detail } = preparedCase;+ const { file, reportFile, testcaseId, index, detail } = preparedCase;if (!preparedCase.input || !preparedCase.key) {if (preparedCase.adaptError) report.errors.push(preparedCase.adaptError);report.details.push(detail);continue;@@ -435,48 +434,83 @@initialTroops: number;deterministic: boolean;thresholds?: Record<string, number>;}): InternalStatAdjustment | undefined {- const shouldCorrect = options.deterministic ? options.game.bias_raw !== 0 : !options.game.passes;- if (!shouldCorrect || options.game.bias_raw === 0) return undefined;+ if (!options.deterministic || options.game.bias_raw === 0) return undefined;const direction = -Math.sign(options.game.bias_raw);- const candidates = statAdjustmentCandidates(direction);- let best: InternalStatAdjustment | undefined;- for (const value of candidates) {- const adjustedInput = inputWithStatAdjustment(options.input, value);- const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);- const adjusted = compareOutcomeDistribution({- candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },- reference: options.reference,- initialTroops: options.initialTroops,- deterministic: options.deterministic,- thresholds: options.thresholds- });- const candidate = {- value,- mode: adjustmentMode(options.game, adjusted, options.deterministic),- unadjusted: options.game,- adjusted: adjustedForRoundingRules(adjusted, options.deterministic)- };- if (!best || correctionScore(candidate.adjusted, options.deterministic) < correctionScore(best.adjusted, options.deterministic)) best = candidate;- if (options.deterministic && candidate.mode === "deterministic_exact") return candidate;+ const maxCandidate = evaluateStatAdjustment(options, direction * STAT_ROUNDING_MAX_ADJUSTMENT);+ let best = maxCandidate;+ if (maxCandidate.mode === "deterministic_exact") return maxCandidate;++ let low = { value: 0, bias: options.game.bias_raw };+ let high = { value: maxCandidate.value, bias: maxCandidate.adjusted.bias_raw };+ if (!biasesBracketZero(low.bias, high.bias)) return maxCandidate;++ const tested = new Set<number>([maxCandidate.value]);+ for (let iteration = 0; iteration < STAT_ROUNDING_INTERPOLATION_LIMIT; iteration += 1) {+ const value = interpolatedZeroAdjustment(low, high);+ if (value === undefined || tested.has(value)) break;+ tested.add(value);++ const candidate = evaluateStatAdjustment(options, value);+ if (correctionScore(candidate.adjusted, options.deterministic) < correctionScore(best.adjusted, options.deterministic)) best = candidate;+ if (candidate.mode === "deterministic_exact") return candidate;++ if (Math.sign(candidate.adjusted.bias_raw) === Math.sign(low.bias)) {+ low = { value: candidate.value, bias: candidate.adjusted.bias_raw };+ } else {+ high = { value: candidate.value, bias: candidate.adjusted.bias_raw };+ }}- if (best && !options.deterministic && best.adjusted.passes) return { ...best, mode: "stochastic_tolerance" };+return best;}-function statAdjustmentCandidates(direction: number): number[] {- const values: number[] = [];- for (let step = STAT_ROUNDING_SCAN_STEPS; step >= 1; step -= 1) {- values.push(roundStatAdjustment(direction * (STAT_ROUNDING_MAX_ADJUSTMENT * step) / STAT_ROUNDING_SCAN_STEPS));- }- return values;+function evaluateStatAdjustment(options: {+ game: ParityComparisonMetrics;+ input: BattleInput;+ config: SimulatorConfig;+ job: TestcaseExecutionJob;+ reference: { n: number; mu: number; sigma: number };+ initialTroops: number;+ deterministic: boolean;+ thresholds?: Record<string, number>;+}, value: number): InternalStatAdjustment {+ const adjustedInput = inputWithStatAdjustment(options.input, value);+ const candidateStats = simulateAdjustedDistribution(adjustedInput, options.job, options.config);+ const adjusted = compareOutcomeDistribution({+ candidate: { n: candidateStats.n, mu: candidateStats.mu, sigma: candidateStats.sigma },+ reference: options.reference,+ initialTroops: options.initialTroops,+ deterministic: options.deterministic,+ thresholds: options.thresholds+ });+ return {+ value: roundStatAdjustment(value),+ mode: adjustmentMode(adjusted, options.deterministic),+ unadjusted: options.game,+ adjusted: adjustedForRoundingRules(adjusted, options.deterministic)+ };}+function biasesBracketZero(first: number, second: number): boolean {+ return first === 0 || second === 0 || Math.sign(first) !== Math.sign(second);+}++function interpolatedZeroAdjustment(low: { value: number; bias: number }, high: { value: number; bias: number }): number | undefined {+ const biasRange = high.bias - low.bias;+ if (biasRange === 0) return undefined;+ const value = low.value - (low.bias * (high.value - low.value)) / biasRange;+ const min = Math.min(low.value, high.value);+ const max = Math.max(low.value, high.value);+ if (value < min || value > max) return undefined;+ return roundStatAdjustment(value);+}+function simulateAdjustedDistribution(input: BattleInput, job: TestcaseExecutionJob, config: SimulatorConfig): SampleStats {const samples: number[] = [];for (let iteration = 0; iteration < job.repeat; iteration += 1) {- const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { detail: "fast" });+ const result = simulateBattle(sampleInput(input, job.seed, job.file, job.testcaseId, job.index, iteration), config, { mode: "fast" });const score = battleScoreDelta(result);if (score !== undefined) samples.push(score);}return sampleStats(samples);@@ -497,9 +531,9 @@}}}-function adjustmentMode(original: ParityComparisonMetrics, adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {+function adjustmentMode(adjusted: ParityComparisonMetrics, deterministic: boolean): TestcaseStatAdjustment["mode"] {if (deterministic) {if (adjusted.bias_raw === 0) return "deterministic_exact";if (Math.abs(adjusted.bias_raw) <= 1) return "deterministic_within_one";return "best_effort";@@ -615,32 +649,31 @@}export function adaptTestcaseEntry(entry: unknown,- options: { seed?: string | number; trace?: boolean; mechanics?: Record<string, unknown> } = {},+ options: { seed?: string | number } = {},diagnostics: string[] = []): BattleInput {const object = entry as {attacker?: FighterInput;defender?: FighterInput;test_id?: string;- mechanics?: Record<string, unknown>;+ mechanics?: { engagement_type?: unknown; engagementType?: unknown };engagement_type?: unknown;engagementType?: unknown;maxRounds?: unknown;max_rounds?: unknown;};if (!object.attacker || !object.defender) throw new Error(`Testcase ${object.test_id ?? "(unknown)"} is missing attacker or defender`);diagnostics.push(...diagnoseFighterShape("attacker", object.attacker), ...diagnoseFighterShape("defender", object.defender));- const mechanics = testcaseMechanics(object, options.mechanics);+ const engagementType = engagementTypeFromEntry(object);const maxRounds = optionalNumber(object.maxRounds ?? object.max_rounds);return {attacker: object.attacker,defender: object.defender,seed: options.seed,- trace: options.trace,...(maxRounds !== undefined ? { maxRounds } : {}),- ...(mechanics ? { mechanics } : {})+ ...(engagementType !== undefined ? { engagement_type: engagementType } : {})};}export function battleScoreDelta(value: unknown): number | undefined {@@ -706,17 +739,16 @@if (!fighter.stats) diagnostics.push(`${side} has no stats block`);return diagnostics;}-function testcaseMechanics(- entry: { mechanics?: Record<string, unknown>; engagement_type?: unknown; engagementType?: unknown },- optionMechanics?: Record<string, unknown>-): Record<string, unknown> | undefined {- const mechanics = entry.mechanics && typeof entry.mechanics === "object" ? { ...entry.mechanics } : {};- if (optionMechanics && typeof optionMechanics === "object") Object.assign(mechanics, optionMechanics);- if (entry.engagement_type !== undefined) mechanics.engagement_type = entry.engagement_type;- if (entry.engagementType !== undefined) mechanics.engagementType = entry.engagementType;- return Object.keys(mechanics).length > 0 ? mechanics : undefined;+function engagementTypeFromEntry(entry: {+ mechanics?: { engagement_type?: unknown; engagementType?: unknown };+ engagement_type?: unknown;+ engagementType?: unknown;+}): string | undefined {+ const value =+ entry.engagement_type ?? entry.engagementType ?? entry.mechanics?.engagement_type ?? entry.mechanics?.engagementType;+ return value === undefined ? undefined : String(value);}function visibilityFromResult(result: BattleResult | undefined): TestcaseCaseReport["visibility"] {if (!result) {Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -161,16 +161,18 @@attacker: FighterInput;defender: FighterInput;seed?: string | number;maxRounds?: number;- trace?: boolean;- mechanics?: Record<string, unknown>;+ // The type of battle (e.g. "rally", "garrison"); gates engagement-specific hero skills.+ engagement_type?: string;}-export type SimulationDetail = "full" | "fast";+// fast: signed-score only (no per-attack outcomes); standard: attack-by-attack+// outcomes without per-attack damage traces; trace: full per-attack equation traces.+export type SimulationMode = "fast" | "standard" | "trace";export interface SimulationOptions {- detail?: SimulationDetail;+ mode?: SimulationMode;}export interface ResolvedTroopLine {id: string;@@ -197,9 +199,8 @@export interface ResolvedHero {name: string;heroGeneration?: string;- generationStats: StatBlock;skillIds: string[];instanceId?: string;role?: "main" | "joiner";missing?: boolean;@@ -276,9 +277,8 @@defenderUnit: UnitType;sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;- carriedAttackDurationEffectIds?: string[];consumedEffectIds?: string[];consumedEffectUseKey?: string;consumedEffectUseId?: string;consumedEffectUseIds?: string[];
Show Raw Dirty State Patch (vs Clean Baseline)
diff --git a/simulator/config/hero_definitions/Mia.json b/simulator/config/hero_definitions/Mia.json--- a/simulator/config/hero_definitions/Mia.json+++ b/simulator/config/hero_definitions/Mia.json@@ -30,7 +30,8 @@},"duration": {"type": "turn",- "value": 1+ "value": 1,+ "delay": 1},"same_effect_stacking": "max"}
Accuracy Results
178 / 178