← Back to Runs
Run Detail
0fcffb9d-9749-4928-83f3-22aec0b251a5
Started27/06/2026, 03:34:30
Git SHAbef5a413
Statedirty
Avg Error %0.20%
BH Sig Count3
Total Cases201
Passing179
Run Report—
Code Changes Since Previous Run
Index: simulator/config/hero_definitions/Gwen.json===================================================================--- simulator/config/hero_definitions/Gwen.json prev run+++ simulator/config/hero_definitions/Gwen.json this run@@ -5,10 +5,10 @@"skills": {"EagleVision": {"description": "Increasing target's damage taken by X%","trigger": {- "type": "attack",- "every": 2+ "type": "turn",+ "source": "self.any"},"effects": {"EagleVision/1": {"type": "active.hero.damageTaken.up",@@ -19,9 +19,10 @@20,25],"units": {- "applies_to": "enemy"+ "applies_to": "trigger.target",+ "applies_vs": "trigger.source"},"duration": {"type": "attack","value": 1@@ -58,9 +59,9 @@"source": "use.source","target": "effect.applies_vs"}]- },+ },"AirDominance/2": {"type": "active.hero.damageTaken.up","value": [5,Index: simulator/src/damage.ts===================================================================--- simulator/src/damage.ts prev run+++ simulator/src/damage.ts this run@@ -61,11 +61,8 @@// 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;}@@ -186,17 +183,13 @@finalKills: kills}: undefined;- const returnedConsumedEffectIds =- consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];+ const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];return {kills,consumedEffectIds: returnedConsumedEffectIds,- consumedEffectUseKey: job.consumedEffectUseKey,- consumedEffectUseId: job.consumedEffectUseId,- consumedEffectUseIds: job.consumedEffectUseIds,appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,appliedEffects: traceEnabled ? appliedEffects : undefined,trace};Index: simulator/src/effectIndex.ts===================================================================--- simulator/src/effectIndex.ts prev run+++ simulator/src/effectIndex.ts this run@@ -57,11 +57,11 @@const slot =definition.role === "attacker"? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit): damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);- const effects = index.damageByJobShape[slot];+ const arr = index.damageByJobShape[slot];const candidate = { effect, bucket: definition.path };- if (effects) effects.push(candidate);+ if (arr) arr.push(candidate);else index.damageByJobShape[slot] = [candidate];}}}@@ -72,22 +72,26 @@compactEffects(index.controls, isActive);compactEffects(index.extraAttacks, isActive);compactEffects(index.battleOrder, isActive);for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const candidates = index.damageByJobShape[slot];- if (!candidates) continue;- compactCandidates(candidates, isActive);- if (candidates.length === 0) index.damageByJobShape[slot] = undefined;+ const arr = index.damageByJobShape[slot];+ if (!arr) continue;+ compactCandidates(arr, isActive);+ if (arr.length === 0) index.damageByJobShape[slot] = undefined;}}export function removeStaticProfileBucketEffects(index: EffectIndex): void {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));- if (candidates.length === 0) index.damageByJobShape[slot] = undefined;+ const arr = index.damageByJobShape[slot];+ if (!arr) continue;+ let write = 0;+ for (let read = 0; read < arr.length; read += 1) {+ if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }+ }+ arr.length = write;+ if (arr.length === 0) index.damageByJobShape[slot] = undefined;}}export function bucketCandidatesForJob(index: EffectIndex, job: DamageJob): IndexedBucketEffect[] {@@ -103,22 +107,21 @@): number {return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);}-function kindIndex(kind: DamageKind): number {- return kind === "normal" ? 0 : 1;-}--function sideIndex(side: SideId): number {- return side === "attacker" ? 0 : 1;-}-+function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }+function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {if (unit === "infantry") return 0;if (unit === "lancer") return 1;return 2;}+function isStaticProfileEffect(effect: ActiveEffect): boolean {+ const definition = bucketDefinition(effect.intent.type);+ return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));+}+function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {let write = 0;for (let read = 0; read < effects.length; read += 1) {const effect = effects[read];@@ -128,18 +131,13 @@}effects.length = write;}-function isStaticProfileEffect(effect: ActiveEffect): boolean {- const definition = bucketDefinition(effect.intent.type);- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));-}--function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect, candidate: IndexedBucketEffect) => boolean): void {+function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {let write = 0;for (let read = 0; read < candidates.length; read += 1) {const candidate = candidates[read];- if (!isActive(candidate.effect, candidate)) continue;+ if (!isActive(candidate.effect)) continue;candidates[write] = candidate;write += 1;}candidates.length = write;Index: simulator/src/recorder.ts===================================================================--- simulator/src/recorder.ts prev run+++ simulator/src/recorder.ts this run@@ -105,11 +105,8 @@],appliedEffectIds: result.appliedEffectIds ?? [],appliedEffects: result.appliedEffects ?? [],consumedEffectIds: result.consumedEffectIds,- consumedEffectUseKey: result.consumedEffectUseKey,- consumedEffectUseId: result.consumedEffectUseId,- consumedEffectUseIds: result.consumedEffectUseIds,trace: result.trace});}Index: simulator/src/simulator.test.ts===================================================================--- simulator/src/simulator.test.ts prev run+++ simulator/src/simulator.test.ts this run@@ -646,12 +646,15 @@}),{ mode: "trace" });+ const roundTwoNormalOutcome = result.attacks.find((attack) => attack.kind === "normal" && attack.jobId.startsWith("r2:attacker:infantry"));const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));+ assert.notEqual(roundTwoNormalOutcome, undefined);+ assert.equal(roundTwoNormalOutcome!.consumedEffectIds.length, 2);assert.notEqual(roundTwoSkillOutcome, undefined);- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 2);+ assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);});test("requires_effect is ignored by native simulator effect activation", () => {const result = simulateBattle(Index: simulator/src/simulator.ts===================================================================--- simulator/src/simulator.ts prev run+++ simulator/src/simulator.ts this run@@ -34,9 +34,9 @@skillMatchesTrigger,type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -48,17 +48,17 @@interface Runtime {activeEffects: ActiveEffect[];effectIndex: EffectIndex;+ effectById: Map<string, ActiveEffect>;staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;skills: RuntimeSkills;skillReports: Record<SideId, Map<string, SkillReportEntry>>;effectActivationCounts: Record<SideId, number>;extraSkillAttackJobsByEffect: Record<string, number>;attackControlCounts: { dodge: number; no_attack: number };- consumedEffectUseKeys: Set<string>;counters: {attacks: Record<SideId, Record<UnitType, number>>;received: Record<SideId, Record<UnitType, number>>;};@@ -77,8 +77,13 @@selected: ActiveEffect;effects: ActiveEffect[];}+interface ExtraSkillJobsResult {+ jobs: DamageJob[];+ consumedEffectIds: string[];+}+interface BattleRun {fighters: Record<SideId, ResolvedFighter>;runtime: Runtime;winner: SideId | "draw";@@ -216,17 +221,17 @@removeStaticProfileBucketEffects(effectIndex);return {activeEffects,effectIndex,+ effectById: new Map(activeEffects.map((e) => [e.id, e])),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,skills: template.skills,skillReports: cloneSkillReports(template.skillReports),effectActivationCounts: { ...template.effectActivationCounts },extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },attackControlCounts: { ...template.attackControlCounts },- consumedEffectUseKeys: new Set(),counters: {attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }}@@ -304,54 +309,79 @@expireInactive(runtime, round);triggerRoundStartSkills(round, runtime, roundStartTroops);const intents = resolveAttackIntents(round, runtime, roundStartTroops);- const jobs: DamageJob[] = [];+ const allJobs: DamageJob[] = []; // for recorder+ const results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];+ // Phase 1: fire all attack_declared triggers — all ActiveEffects are+ // resolved before any damage is calculated, per the battle-core spec.+ const pendingNormalJobs: DamageJob[] = [];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 consumedEffectIds = attackDurationEffectIdsForJob(job, round, runtime.activeEffects);+ const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);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));+ pendingNormalJobs.push(job);}}- const results: DamageJobResult[] = [];- for (const job of jobs) {- const result = calculateDamageJob(job, fighters, runtime.activeEffects, {+ // Phase 2: calculate each normal job; any extra_skill_attack effect it+ // uses spawns extra DamageJobs that are calculated immediately after.+ // consumeEffects runs after each job so subsequent normal jobs see the+ // correct uses count on any shared extra_skill_attack effects.+ for (const job of pendingNormalJobs) {+ allJobs.push(job);+ const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,capToDefenderTroops: loopOptions.capJobKills});- results.push({ job, result });- if (- loopOptions.scoreSide &&- job.attackerSide === loopOptions.scoreSide.attackerSide &&- job.defenderSide === loopOptions.scoreSide.defenderSide- ) {- score += result.kills;+ results.push({ job, result: normalResult });+ if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {+ score += normalResult.kills;}- consumeEffects(runtime, result.consumedEffectIds, result.consumedEffectUseKey, result.consumedEffectUseId, result.consumedEffectUseIds);+ consumeEffects(runtime, normalResult.consumedEffectIds);++ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);+ if (extraSkill.consumedEffectIds.length > 0) {+ normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];+ consumeEffects(runtime, extraSkill.consumedEffectIds);+ }++ for (const extraJob of extraSkill.jobs) {+ allJobs.push(extraJob);+ const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {+ trace: recorder.capturesTrace,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,+ capToDefenderTroops: loopOptions.capJobKills+ });+ results.push({ job: extraJob, result: extraResult });+ if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {+ score += extraResult.kills;+ }+ consumeEffects(runtime, extraResult.consumedEffectIds);+ }}if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);if (loopOptions.commitLosses) commitRound(cancelled, results, fighters, runtime);else commitRoundCounters(cancelled, results, 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);+ recorder.recordRound(round, roundStartTroops, intents, allJobs);}const winner = winnerFor(fighters) ?? "draw";return {@@ -498,17 +528,17 @@}return {activeEffects: [],effectIndex: createEffectIndex(),+ effectById: new Map(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,skills,skillReports: reports,effectActivationCounts: { attacker: 0, defender: 0 },extraSkillAttackJobsByEffect: {},attackControlCounts: { dodge: 0, no_attack: 0 },- consumedEffectUseKeys: new Set(),counters: {attacks: { attacker: emptyTroops(), defender: emptyTroops() },received: { attacker: emptyTroops(), defender: emptyTroops() }}@@ -531,11 +561,25 @@}function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {runtime.activeEffects.push(effect);+ runtime.effectById.set(effect.id, effect);indexEffect(runtime.effectIndex, effect);}+function expireInactive(runtime: Runtime, round: number): void {+ let write = 0;+ for (let read = 0; read < runtime.activeEffects.length; read += 1) {+ const effect = runtime.activeEffects[read];+ if (isEffectActive(effect, round)) {+ runtime.activeEffects[write] = effect;+ write += 1;+ }+ }+ runtime.activeEffects.length = write;+ pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));+}+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {if (!passive) return;for (const stat of ["attack", "defense", "lethality", "health"] as const) {for (const direction of ["up", "down"] as const) {@@ -696,19 +740,18 @@normalAttack: DamageJob,round: number,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"]-): DamageJob[] {+): ExtraSkillJobsResult {const jobs: DamageJob[] = [];+ const consumedEffectIds: string[] = [];const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round);for (const effectGroup of effectGroups) {const effect = effectGroup.selected;const definitions = effect.triggerDamageJobs ?? [];- const consumedEffectIds = effectGroup.effects.map((groupEffect) => groupEffect.id);- const consumedEffectUseKey = `${normalAttack.sourceIntentId}:${effect.stackingKey ?? effect.id}`;const firstJobIndex = jobs.length;let definitionIndex = 0;for (const definition of definitions) {const sources = resolveTriggerJobSelector(definition.source, "source", effect, normalAttack, roundStartTroops);@@ -732,24 +775,20 @@defenderSide: target.side,defenderUnit: target.unit,sourceEffectId,sourceSkillReportKey,- sourceMultiplier: multiplier,- consumedEffectIds,- consumedEffectUseKey,- consumedEffectUseId: effect.id,- consumedEffectUseIds: consumedEffectIds+ sourceMultiplier: multiplier});runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;}}definitionIndex += 1;}if (jobs.length > firstJobIndex) {- for (const consumedEffectId of consumedEffectIds) reserveConsumedEffectUse(runtime, consumedEffectUseKey, consumedEffectId);+ consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));}}- return jobs;+ return { jobs, consumedEffectIds };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {const selected: ExtraAttackEffectGroup[] = [];@@ -886,60 +925,28 @@runtime.counters.received[job.defenderSide][job.defenderUnit] += 1;}}-function expireInactive(runtime: Runtime, round: number): void {- let write = 0;- for (let read = 0; read < runtime.activeEffects.length; read += 1) {- const effect = runtime.activeEffects[read];- if (isEffectActive(effect, round)) {- runtime.activeEffects[write] = effect;- write += 1;- }+function attackDurationEffectIdsForJob(job: DamageJob, index: EffectIndex): string[] {+ const ids: string[] = [];+ for (const candidate of bucketCandidatesForJob(index, job)) {+ if (candidate.effect.duration.type === "attack") ids.push(candidate.effect.id);}- runtime.activeEffects.length = write;- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));+ for (const effect of index.controls) {+ if (effect.duration.type !== "attack") continue;+ const classification = classifyEffectForJob(effect, job);+ if (classification?.kind === "control") ids.push(effect.id);+ }+ return ids;}-function attackDurationEffectIdsForJob(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" || classification?.kind === "control";- })- .map((effect) => effect.id);-}--function consumeEffects(- runtime: Runtime,- consumedEffectIds: string[],- consumedEffectUseKey?: string,- consumedEffectUseId?: string,- consumedEffectUseIds?: string[]-): void {- const dedupeIds = consumedEffectUseIds ?? (consumedEffectUseId ? [consumedEffectUseId] : undefined);+function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {for (const consumed of consumedEffectIds) {- if (consumedEffectUseKey && dedupeIds?.includes(consumed)) {- const useKey = `${consumedEffectUseKey}:${consumed}`;- if (runtime.consumedEffectUseKeys.has(useKey)) continue;- runtime.consumedEffectUseKeys.add(useKey);- }- for (const effect of runtime.activeEffects) {- if (effect.id === consumed) effect.uses += 1;- }+ const effect = runtime.effectById.get(consumed);+ if (effect) effect.uses += 1;}}-function reserveConsumedEffectUse(runtime: Runtime, consumedEffectUseKey: string, consumedEffectId: string): void {- const useKey = `${consumedEffectUseKey}:${consumedEffectId}`;- if (runtime.consumedEffectUseKeys.has(useKey)) return;- runtime.consumedEffectUseKeys.add(useKey);- for (const effect of runtime.activeEffects) {- if (effect.id === consumedEffectId) effect.uses += 1;- }-}-function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {const attackerAlive = total(fighters.attacker.troops) > 0;const defenderAlive = total(fighters.defender.troops) > 0;if (attackerAlive && !defenderAlive) return "attacker";Index: simulator/src/types.ts===================================================================--- simulator/src/types.ts prev run+++ simulator/src/types.ts this run@@ -277,12 +277,8 @@defenderUnit: UnitType;sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;- consumedEffectIds?: string[];- consumedEffectUseKey?: string;- consumedEffectUseId?: string;- consumedEffectUseIds?: string[];}export interface CounterDelta {side: SideId;@@ -343,11 +339,8 @@counterDeltas: CounterDelta[];appliedEffectIds: string[];appliedEffects: AppliedEffectTrace[];consumedEffectIds: string[];- consumedEffectUseKey?: string;- consumedEffectUseId?: string;- consumedEffectUseIds?: string[];cancelledBy?: string;cancelReason?: "dodge" | "no_attack";trace?: DamageEquationTrace;}
Show Raw Dirty State Patch (vs Clean Baseline)
diff --git a/simulator/config/hero_definitions/Gwen.json b/simulator/config/hero_definitions/Gwen.json--- a/simulator/config/hero_definitions/Gwen.json+++ b/simulator/config/hero_definitions/Gwen.json@@ -6,8 +6,8 @@"EagleVision": {"description": "Increasing target's damage taken by X%","trigger": {- "type": "attack",- "every": 2+ "type": "turn",+ "source": "self.any"},"effects": {"EagleVision/1": {@@ -20,7 +20,8 @@25],"units": {- "applies_to": "trigger.target"+ "applies_to": "trigger.target",+ "applies_vs": "trigger.source"},"duration": {"type": "attack",diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts--- a/simulator/src/damage.ts+++ b/simulator/src/damage.ts@@ -62,9 +62,6 @@export interface DamageResult {kills: number;consumedEffectIds: string[];- consumedEffectUseKey?: string;- consumedEffectUseId?: string;- consumedEffectUseIds?: string[];appliedEffectIds?: string[];appliedEffects?: DamageEquationTrace["appliedEffects"];trace?: DamageEquationTrace;@@ -187,15 +184,11 @@}: undefined;- const returnedConsumedEffectIds =- consumedEffectIds.size === 0 ? job.consumedEffectIds ?? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds, ...(job.consumedEffectIds ?? [])];+ const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];return {kills,consumedEffectIds: returnedConsumedEffectIds,- consumedEffectUseKey: job.consumedEffectUseKey,- consumedEffectUseId: job.consumedEffectUseId,- consumedEffectUseIds: job.consumedEffectUseIds,appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,appliedEffects: traceEnabled ? appliedEffects : undefined,tracediff --git a/simulator/src/effectIndex.ts b/simulator/src/effectIndex.ts--- a/simulator/src/effectIndex.ts+++ b/simulator/src/effectIndex.ts@@ -58,9 +58,9 @@definition.role === "attacker"? damageJobShapeSlot(jobKind, effect.appliesTo.side, appliesToUnit, effect.appliesVs.side, appliesVsUnit): damageJobShapeSlot(jobKind, effect.appliesVs.side, appliesVsUnit, effect.appliesTo.side, appliesToUnit);- const effects = index.damageByJobShape[slot];+ const arr = index.damageByJobShape[slot];const candidate = { effect, bucket: definition.path };- if (effects) effects.push(candidate);+ if (arr) arr.push(candidate);else index.damageByJobShape[slot] = [candidate];}}@@ -73,20 +73,24 @@compactEffects(index.extraAttacks, isActive);compactEffects(index.battleOrder, isActive);for (let slot = 0; slot < index.damageByJobShape.length; slot += 1) {- const candidates = index.damageByJobShape[slot];- if (!candidates) continue;- compactCandidates(candidates, isActive);- if (candidates.length === 0) index.damageByJobShape[slot] = undefined;+ const arr = index.damageByJobShape[slot];+ if (!arr) continue;+ compactCandidates(arr, isActive);+ if (arr.length === 0) index.damageByJobShape[slot] = undefined;}}export function removeStaticProfileBucketEffects(index: EffectIndex): void {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));- if (candidates.length === 0) index.damageByJobShape[slot] = undefined;+ const arr = index.damageByJobShape[slot];+ if (!arr) continue;+ let write = 0;+ for (let read = 0; read < arr.length; read += 1) {+ if (!isStaticProfileBucket(arr[read].bucket)) { arr[write] = arr[read]; write += 1; }+ }+ arr.length = write;+ if (arr.length === 0) index.damageByJobShape[slot] = undefined;}}@@ -104,20 +108,19 @@return (((kindIndex(jobKind) * 2 + sideIndex(attackerSide)) * 3 + unitIndex(attackerUnit)) * 2 + sideIndex(defenderSide)) * 3 + unitIndex(defenderUnit);}-function kindIndex(kind: DamageKind): number {- return kind === "normal" ? 0 : 1;-}--function sideIndex(side: SideId): number {- return side === "attacker" ? 0 : 1;-}-+function kindIndex(kind: DamageKind): number { return kind === "normal" ? 0 : 1; }+function sideIndex(side: SideId): number { return side === "attacker" ? 0 : 1; }function unitIndex(unit: UnitType): number {if (unit === "infantry") return 0;if (unit === "lancer") return 1;return 2;}+function isStaticProfileEffect(effect: ActiveEffect): boolean {+ const definition = bucketDefinition(effect.intent.type);+ return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));+}+function compactEffects(effects: ActiveEffect[], isActive: (effect: ActiveEffect) => boolean): void {let write = 0;for (let read = 0; read < effects.length; read += 1) {@@ -129,16 +132,11 @@effects.length = write;}-function isStaticProfileEffect(effect: ActiveEffect): boolean {- const definition = bucketDefinition(effect.intent.type);- return isStaticProfileBucket(effect.intent.type) || (definition !== undefined && isStaticProfileBucket(definition.path));-}--function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect, candidate: IndexedBucketEffect) => boolean): void {+function compactCandidates(candidates: IndexedBucketEffect[], isActive: (effect: ActiveEffect) => boolean): void {let write = 0;for (let read = 0; read < candidates.length; read += 1) {const candidate = candidates[read];- if (!isActive(candidate.effect, candidate)) continue;+ if (!isActive(candidate.effect)) continue;candidates[write] = candidate;write += 1;}diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts--- a/simulator/src/recorder.ts+++ b/simulator/src/recorder.ts@@ -106,9 +106,6 @@appliedEffectIds: result.appliedEffectIds ?? [],appliedEffects: result.appliedEffects ?? [],consumedEffectIds: result.consumedEffectIds,- consumedEffectUseKey: result.consumedEffectUseKey,- consumedEffectUseId: result.consumedEffectUseId,- consumedEffectUseIds: result.consumedEffectUseIds,trace: result.trace});}diff --git a/simulator/src/simulator.test.ts b/simulator/src/simulator.test.ts--- a/simulator/src/simulator.test.ts+++ b/simulator/src/simulator.test.ts@@ -647,10 +647,13 @@{ mode: "trace" });+ const roundTwoNormalOutcome = result.attacks.find((attack) => attack.kind === "normal" && attack.jobId.startsWith("r2:attacker:infantry"));const roundTwoSkillOutcome = result.attacks.find((attack) => attack.kind === "skill" && attack.jobId.startsWith("r2:attacker:infantry"));+ assert.notEqual(roundTwoNormalOutcome, undefined);+ assert.equal(roundTwoNormalOutcome!.consumedEffectIds.length, 2);assert.notEqual(roundTwoSkillOutcome, undefined);- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 2);+ assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);});test("requires_effect is ignored by native simulator effect activation", () => {diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts--- a/simulator/src/simulator.ts+++ b/simulator/src/simulator.ts@@ -35,7 +35,7 @@type Rng} from "./effects";import { classifyEffectForJob } from "./classifier";-import { createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";+import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";import { normalizeUnitType } from "./normalize";import { emptyTroops, resolveFighter } from "./resolve";import { buildStaticDamageProfile, type StaticDamageProfile } from "./staticDamageProfile";@@ -49,6 +49,7 @@interface Runtime {activeEffects: ActiveEffect[];effectIndex: EffectIndex;+ effectById: Map<string, ActiveEffect>;staticDamageProfile?: StaticDamageProfile;damageScratch: DamageScratch;rng: Rng;@@ -57,7 +58,6 @@effectActivationCounts: Record<SideId, number>;extraSkillAttackJobsByEffect: Record<string, number>;attackControlCounts: { dodge: number; no_attack: number };- consumedEffectUseKeys: Set<string>;counters: {attacks: Record<SideId, Record<UnitType, number>>;received: Record<SideId, Record<UnitType, number>>;@@ -78,6 +78,11 @@effects: ActiveEffect[];}+interface ExtraSkillJobsResult {+ jobs: DamageJob[];+ consumedEffectIds: string[];+}+interface BattleRun {fighters: Record<SideId, ResolvedFighter>;runtime: Runtime;@@ -217,6 +222,7 @@return {activeEffects,effectIndex,+ effectById: new Map(activeEffects.map((e) => [e.id, e])),staticDamageProfile: template.staticDamageProfile,damageScratch: createFastDamageScratch(),rng,@@ -225,7 +231,6 @@effectActivationCounts: { ...template.effectActivationCounts },extraSkillAttackJobsByEffect: { ...template.extraSkillAttackJobsByEffect },attackControlCounts: { ...template.attackControlCounts },- consumedEffectUseKeys: new Set(),counters: {attacks: { attacker: { ...template.counters.attacks.attacker }, defender: { ...template.counters.attacks.defender } },received: { attacker: { ...template.counters.received.attacker }, defender: { ...template.counters.received.defender } }@@ -305,9 +310,13 @@triggerRoundStartSkills(round, runtime, roundStartTroops);const intents = resolveAttackIntents(round, runtime, roundStartTroops);- const jobs: DamageJob[] = [];+ const allJobs: DamageJob[] = []; // for recorder+ const results: DamageJobResult[] = [];const cancelled: CancelledAttack[] = [];+ // Phase 1: fire all attack_declared triggers — all ActiveEffects are+ // resolved before any damage is calculated, per the battle-core spec.+ const pendingNormalJobs: DamageJob[] = [];for (const intent of intents) {triggerSkills("attack_declared", round, runtime.skills.attackDeclared, runtime, intent);const job = normalJob(intent, roundStartTroops);@@ -315,33 +324,54 @@if (controls.no_attack || controls.dodge) {const control = controls.no_attack ?? controls.dodge!;runtime.attackControlCounts[control.reason] += 1;- const consumedEffectIds = attackDurationEffectIdsForJob(job, round, runtime.activeEffects);+ const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);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));+ pendingNormalJobs.push(job);}}- const results: DamageJobResult[] = [];- for (const job of jobs) {- const result = calculateDamageJob(job, fighters, runtime.activeEffects, {+ // Phase 2: calculate each normal job; any extra_skill_attack effect it+ // uses spawns extra DamageJobs that are calculated immediately after.+ // consumeEffects runs after each job so subsequent normal jobs see the+ // correct uses count on any shared extra_skill_attack effects.+ for (const job of pendingNormalJobs) {+ allJobs.push(job);+ const normalResult = calculateDamageJob(job, fighters, runtime.activeEffects, {trace: recorder.capturesTrace,effectIndex: runtime.effectIndex,staticDamageProfile: runtime.staticDamageProfile,scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,capToDefenderTroops: loopOptions.capJobKills});- results.push({ job, result });- if (- loopOptions.scoreSide &&- job.attackerSide === loopOptions.scoreSide.attackerSide &&- job.defenderSide === loopOptions.scoreSide.defenderSide- ) {- score += result.kills;+ results.push({ job, result: normalResult });+ if (loopOptions.scoreSide && job.attackerSide === loopOptions.scoreSide.attackerSide && job.defenderSide === loopOptions.scoreSide.defenderSide) {+ score += normalResult.kills;+ }+ consumeEffects(runtime, normalResult.consumedEffectIds);++ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);+ if (extraSkill.consumedEffectIds.length > 0) {+ normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];+ consumeEffects(runtime, extraSkill.consumedEffectIds);+ }++ for (const extraJob of extraSkill.jobs) {+ allJobs.push(extraJob);+ const extraResult = calculateDamageJob(extraJob, fighters, runtime.activeEffects, {+ trace: recorder.capturesTrace,+ effectIndex: runtime.effectIndex,+ staticDamageProfile: runtime.staticDamageProfile,+ scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,+ capToDefenderTroops: loopOptions.capJobKills+ });+ results.push({ job: extraJob, result: extraResult });+ if (loopOptions.scoreSide && extraJob.attackerSide === loopOptions.scoreSide.attackerSide && extraJob.defenderSide === loopOptions.scoreSide.defenderSide) {+ score += extraResult.kills;+ }+ consumeEffects(runtime, extraResult.consumedEffectIds);}- consumeEffects(runtime, result.consumedEffectIds, result.consumedEffectUseKey, result.consumedEffectUseId, result.consumedEffectUseIds);}if (loopOptions.capRoundKills) capRoundKills(results, roundStartTroops);@@ -350,7 +380,7 @@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);+ recorder.recordRound(round, roundStartTroops, intents, allJobs);}const winner = winnerFor(fighters) ?? "draw";@@ -499,6 +529,7 @@return {activeEffects: [],effectIndex: createEffectIndex(),+ effectById: new Map(),staticDamageProfile: undefined,damageScratch: createFastDamageScratch(),rng,@@ -507,7 +538,6 @@effectActivationCounts: { attacker: 0, defender: 0 },extraSkillAttackJobsByEffect: {},attackControlCounts: { dodge: 0, no_attack: 0 },- consumedEffectUseKeys: new Set(),counters: {attacks: { attacker: emptyTroops(), defender: emptyTroops() },received: { attacker: emptyTroops(), defender: emptyTroops() }@@ -532,9 +562,23 @@function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {runtime.activeEffects.push(effect);+ runtime.effectById.set(effect.id, effect);indexEffect(runtime.effectIndex, effect);}+function expireInactive(runtime: Runtime, round: number): void {+ let write = 0;+ for (let read = 0; read < runtime.activeEffects.length; read += 1) {+ const effect = runtime.activeEffects[read];+ if (isEffectActive(effect, round)) {+ runtime.activeEffects[write] = effect;+ write += 1;+ }+ }+ runtime.activeEffects.length = write;+ pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));+}+function addInputPassiveEffects(runtime: Runtime, passive: FighterInput["passive"], side: SideId): void {if (!passive) return;for (const stat of ["attack", "defense", "lethality", "health"] as const) {@@ -697,8 +741,9 @@round: number,runtime: Runtime,roundStartTroops: DamageJob["roundStartTroops"]-): DamageJob[] {+): ExtraSkillJobsResult {const jobs: DamageJob[] = [];+ const consumedEffectIds: string[] = [];const effectGroups = selectStackedExtraAttackEffectGroups(runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),round@@ -706,8 +751,6 @@for (const effectGroup of effectGroups) {const effect = effectGroup.selected;const definitions = effect.triggerDamageJobs ?? [];- const consumedEffectIds = effectGroup.effects.map((groupEffect) => groupEffect.id);- const consumedEffectUseKey = `${normalAttack.sourceIntentId}:${effect.stackingKey ?? effect.id}`;const firstJobIndex = jobs.length;let definitionIndex = 0;for (const definition of definitions) {@@ -733,11 +776,7 @@defenderUnit: target.unit,sourceEffectId,sourceSkillReportKey,- sourceMultiplier: multiplier,- consumedEffectIds,- consumedEffectUseKey,- consumedEffectUseId: effect.id,- consumedEffectUseIds: consumedEffectIds+ sourceMultiplier: multiplier});runtime.extraSkillAttackJobsByEffect[sourceEffectId] = (runtime.extraSkillAttackJobsByEffect[sourceEffectId] ?? 0) + 1;}@@ -745,10 +784,10 @@definitionIndex += 1;}if (jobs.length > firstJobIndex) {- for (const consumedEffectId of consumedEffectIds) reserveConsumedEffectUse(runtime, consumedEffectUseKey, consumedEffectId);+ consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));}}- return jobs;+ return { jobs, consumedEffectIds };}function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {@@ -887,55 +926,23 @@}}-function expireInactive(runtime: Runtime, round: number): void {- let write = 0;- for (let read = 0; read < runtime.activeEffects.length; read += 1) {- const effect = runtime.activeEffects[read];- if (isEffectActive(effect, round)) {- runtime.activeEffects[write] = effect;- write += 1;- }+function attackDurationEffectIdsForJob(job: DamageJob, index: EffectIndex): string[] {+ const ids: string[] = [];+ for (const candidate of bucketCandidatesForJob(index, job)) {+ if (candidate.effect.duration.type === "attack") ids.push(candidate.effect.id);}- runtime.activeEffects.length = write;- pruneEffectIndex(runtime.effectIndex, (effect) => isEffectActive(effect, round));-}--function attackDurationEffectIdsForJob(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" || classification?.kind === "control";- })- .map((effect) => effect.id);-}--function consumeEffects(- runtime: Runtime,- consumedEffectIds: string[],- consumedEffectUseKey?: string,- consumedEffectUseId?: string,- consumedEffectUseIds?: string[]-): void {- const dedupeIds = consumedEffectUseIds ?? (consumedEffectUseId ? [consumedEffectUseId] : undefined);- for (const consumed of consumedEffectIds) {- if (consumedEffectUseKey && dedupeIds?.includes(consumed)) {- const useKey = `${consumedEffectUseKey}:${consumed}`;- if (runtime.consumedEffectUseKeys.has(useKey)) continue;- runtime.consumedEffectUseKeys.add(useKey);- }- for (const effect of runtime.activeEffects) {- if (effect.id === consumed) effect.uses += 1;- }+ for (const effect of index.controls) {+ if (effect.duration.type !== "attack") continue;+ const classification = classifyEffectForJob(effect, job);+ if (classification?.kind === "control") ids.push(effect.id);}+ return ids;}-function reserveConsumedEffectUse(runtime: Runtime, consumedEffectUseKey: string, consumedEffectId: string): void {- const useKey = `${consumedEffectUseKey}:${consumedEffectId}`;- if (runtime.consumedEffectUseKeys.has(useKey)) return;- runtime.consumedEffectUseKeys.add(useKey);- for (const effect of runtime.activeEffects) {- if (effect.id === consumedEffectId) effect.uses += 1;+function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {+ for (const consumed of consumedEffectIds) {+ const effect = runtime.effectById.get(consumed);+ if (effect) effect.uses += 1;}}diff --git a/simulator/src/types.ts b/simulator/src/types.ts--- a/simulator/src/types.ts+++ b/simulator/src/types.ts@@ -278,10 +278,6 @@sourceEffectId?: string;sourceSkillReportKey?: string;sourceMultiplier?: number;- consumedEffectIds?: string[];- consumedEffectUseKey?: string;- consumedEffectUseId?: string;- consumedEffectUseIds?: string[];}export interface CounterDelta {@@ -344,9 +340,6 @@appliedEffectIds: string[];appliedEffects: AppliedEffectTrace[];consumedEffectIds: string[];- consumedEffectUseKey?: string;- consumedEffectUseId?: string;- consumedEffectUseIds?: string[];cancelledBy?: string;cancelReason?: "dodge" | "no_attack";trace?: DamageEquationTrace;
Accuracy Results
201 / 201