WOS Sim
← Back to Runs

Run Detail

ea252b07-0d3b-4aca-b885-aa293e895921

Started04/07/2026, 21:12:47
Git SHA79ed9788
Statedirty
Avg Error %0.20%
BH Sig Count3
Total Cases201
Passing181
Run Report

Code Changes Since Previous Run

Index: simulator/config/hero_definitions/Ahmose.json
===================================================================
--- simulator/config/hero_definitions/Ahmose.json prev run
+++ simulator/config/hero_definitions/Ahmose.json this run
@@ -18,10 +18,11 @@
"infantry"
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
},
"ViperFormation/2": {
"type": "active.hero.damageTaken.down",
@@ -38,11 +39,12 @@
"marksman"
]
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
},
"ViperFormation/3": {
@@ -59,11 +61,12 @@
"infantry"
]
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -112,10 +115,11 @@
"infantry"
]
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
},
"BladeOfLight/2": {
"type": "active.hero.damageTaken.up",
@@ -129,10 +133,11 @@
"units": {
"applies_to": "target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "add"
}
}
Index: simulator/config/hero_definitions/Alonso.json
===================================================================
--- simulator/config/hero_definitions/Alonso.json prev run
+++ simulator/config/hero_definitions/Alonso.json this run
@@ -30,10 +30,11 @@
"applies_to": "trigger",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
},
@@ -63,11 +64,12 @@
"applies_to": "target",
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -98,10 +100,11 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
{
Index: simulator/config/hero_definitions/Bahiti.json
===================================================================
--- simulator/config/hero_definitions/Bahiti.json prev run
+++ simulator/config/hero_definitions/Bahiti.json this run
@@ -46,10 +46,11 @@
"units": {
"applies_to": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
}
Index: simulator/config/hero_definitions/Bradley.json
===================================================================
--- simulator/config/hero_definitions/Bradley.json prev run
+++ simulator/config/hero_definitions/Bradley.json this run
@@ -75,10 +75,11 @@
24,
30
],
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
}
}
Index: simulator/config/hero_definitions/Gordon.json
===================================================================
--- simulator/config/hero_definitions/Gordon.json prev run
+++ simulator/config/hero_definitions/Gordon.json this run
@@ -25,10 +25,11 @@
"lancer"
]
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
},
"VenomInfusion/2": {
"type": "active.hero.damage.down",
@@ -42,10 +43,11 @@
"units": {
"applies_to": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
},
@@ -70,10 +72,11 @@
"lancer"
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
},
"ChemicalTerror/2": {
@@ -89,10 +92,11 @@
"applies_to": "enemy.any",
"applies_vs": "self.any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -116,10 +120,11 @@
"units": {
"applies_to": "enemy.infantry"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
},
"ToxicRelease/2": {
@@ -134,10 +139,11 @@
"units": {
"applies_to": "enemy.marksman"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
}
}
@@ -150,9 +156,9 @@
"requirements": [
{
"level": 1,
"type": "engagement_type",
- "value": "garrison"
+ "value": "rally"
}
],
"effects": {
"BioAssault/1": {
Index: simulator/config/hero_definitions/Greg.json
===================================================================
--- simulator/config/hero_definitions/Greg.json prev run
+++ simulator/config/hero_definitions/Greg.json this run
@@ -30,10 +30,11 @@
"applies_to": "all",
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 3
+ "turns": {
+ "count": 3
+ }
}
}
}
},
@@ -64,10 +65,11 @@
"applies_to": "trigger.target",
"applies_vs": "self.any"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
}
}
},
Index: simulator/config/hero_definitions/Gwen.json
===================================================================
--- simulator/config/hero_definitions/Gwen.json prev run
+++ simulator/config/hero_definitions/Gwen.json this run
@@ -23,10 +23,11 @@
"applies_to": "trigger.target",
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
},
@@ -50,18 +51,19 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
"target": "effect.applies_vs"
}
]
- },
+ },
"AirDominance/2": {
"type": "active.hero.damageTaken.up",
"value": [
5,
@@ -73,11 +75,12 @@
"units": {
"applies_to": "target"
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "attacks": {
+ "count": 1,
+ "delay": 1
+ }
}
}
}
},
@@ -103,11 +106,12 @@
"marksman"
]
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "attacks": {
+ "count": 1,
+ "delay": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
Index: simulator/config/hero_definitions/Hector.json
===================================================================
--- simulator/config/hero_definitions/Hector.json prev run
+++ simulator/config/hero_definitions/Hector.json this run
@@ -25,10 +25,11 @@
40,
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -54,10 +55,11 @@
],
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 10
+ "attacks": {
+ "count": 10
+ }
},
"value_evolution": {
"type": "pct_decay",
"step": "attack",
@@ -79,10 +81,11 @@
],
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 10
+ "attacks": {
+ "count": 10
+ }
},
"value_evolution": {
"type": "pct_decay",
"step": "attack",
@@ -117,10 +120,11 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
Index: simulator/config/hero_definitions/Jeronimo.json
===================================================================
--- simulator/config/hero_definitions/Jeronimo.json prev run
+++ simulator/config/hero_definitions/Jeronimo.json this run
@@ -59,10 +59,11 @@
"applies_to": "all",
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
}
}
},
Index: simulator/config/hero_definitions/Lynn.json
===================================================================
--- simulator/config/hero_definitions/Lynn.json prev run
+++ simulator/config/hero_definitions/Lynn.json this run
@@ -25,10 +25,11 @@
40,
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
}
Index: simulator/config/hero_definitions/Mia.json
===================================================================
--- simulator/config/hero_definitions/Mia.json prev run
+++ simulator/config/hero_definitions/Mia.json this run
@@ -28,10 +28,11 @@
"units": {
"applies_to": "target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -62,10 +63,11 @@
"applies_to": "trigger.source",
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
{
@@ -98,10 +100,11 @@
40,
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
}
Index: simulator/config/hero_definitions/Molly.json
===================================================================
--- simulator/config/hero_definitions/Molly.json prev run
+++ simulator/config/hero_definitions/Molly.json this run
@@ -28,10 +28,11 @@
"units": {
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
},
@@ -61,10 +62,11 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
Index: simulator/config/hero_definitions/Natalia.json
===================================================================
--- simulator/config/hero_definitions/Natalia.json prev run
+++ simulator/config/hero_definitions/Natalia.json this run
@@ -28,10 +28,11 @@
"units": {
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
},
Index: simulator/config/hero_definitions/Norah.json
===================================================================
--- simulator/config/hero_definitions/Norah.json prev run
+++ simulator/config/hero_definitions/Norah.json this run
@@ -73,10 +73,11 @@
],
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
{
@@ -108,10 +109,11 @@
"applies_to": "all",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
},
"Momentum/2": {
"type": "active.hero.damageTaken.down",
@@ -122,11 +124,12 @@
20,
25
],
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
}
}
}
},
Index: simulator/config/hero_definitions/Philly.json
===================================================================
--- simulator/config/hero_definitions/Philly.json prev run
+++ simulator/config/hero_definitions/Philly.json this run
@@ -57,10 +57,11 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
@@ -95,10 +96,11 @@
"units": {
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
},
Index: simulator/config/hero_definitions/Reina.json
===================================================================
--- simulator/config/hero_definitions/Reina.json prev run
+++ simulator/config/hero_definitions/Reina.json this run
@@ -47,10 +47,11 @@
"units": {
"applies_to": "trigger"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
},
@@ -83,10 +84,11 @@
],
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
{
Index: simulator/config/hero_definitions/Renee.json
===================================================================
--- simulator/config/hero_definitions/Renee.json prev run
+++ simulator/config/hero_definitions/Renee.json this run
@@ -24,11 +24,15 @@
"applies_to": "target",
"applies_vs": "lancer"
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ },
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -54,11 +58,12 @@
"applies_to": "target",
"applies_vs": "lancer"
},
"duration": {
- "type": "turn",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
}
@@ -84,11 +89,12 @@
"applies_to": "target",
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
}
Index: simulator/config/hero_definitions/Wayne.json
===================================================================
--- simulator/config/hero_definitions/Wayne.json prev run
+++ simulator/config/hero_definitions/Wayne.json this run
@@ -24,10 +24,11 @@
"applies_to": "trigger.source",
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
{
@@ -59,10 +60,11 @@
"applies_to": "trigger.source",
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
@@ -85,10 +87,11 @@
"applies_to": "trigger.source",
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
@@ -126,10 +129,11 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
Index: simulator/config/troop_skills.json
===================================================================
--- simulator/config/troop_skills.json prev run
+++ simulator/config/troop_skills.json this run
@@ -96,10 +96,11 @@
"applies_to": "trigger.target",
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
},
@@ -205,10 +206,11 @@
"marksman"
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
},
@@ -248,10 +250,11 @@
],
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
@@ -289,10 +292,11 @@
"applies_to": "trigger.target",
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
},
@@ -353,10 +357,11 @@
"applies_to": "trigger.source",
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
@@ -402,10 +407,11 @@
],
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
@@ -469,10 +475,11 @@
],
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
"source": "use.source",
Index: simulator/src/classifierDamage.test.ts
===================================================================
--- simulator/src/classifierDamage.test.ts prev run
+++ simulator/src/classifierDamage.test.ts this run
@@ -100,9 +100,11 @@
for (const activeEffect of effects) indexEffect(effectIndex, activeEffect);
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
removeStaticProfileBucketEffects(effectIndex);
- return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });
+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
+ const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
}
test("classifier routes up/down effects into neutral atomic buckets", () => {
assert.equal(classifyEffectForJob(effect("active.hero.health.up", "defender"), job)?.bucket, "active.hero.health.up");
@@ -411,22 +413,22 @@
assert.equal(skillOutcome.trace?.aggregationGroups["type.normal.damage.up"], undefined);
assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
});
-test("attack-duration bucket effects are consumed by the applicable attack job", () => {
+test("attack-duration bucket effects are charged by the applicable attack job", () => {
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
id: "attack-up-active",
duration: { type: "attack" as const, value: 1 }
};
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
- assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));
+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));
assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
});
-test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {
+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {
const turnEffect = {
...effect("active.hero.defense.down", "defender", 30),
id: "bad-luck-like",
source: { ...effect("active.hero.defense.down", "defender", 30).source, effectId: "BadLuckStreak/1" },
@@ -434,12 +436,13 @@
};
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.usedEffectIds.includes("bad-luck-like"));
assert.deepEqual(outcome.appliedEffects, [
{
+ kind: "modifier",
+ activeEffectId: "bad-luck-like",
effectId: "BadLuckStreak/1",
bucket: "active.hero.defense.down",
valuePct: 30,
source: "Example/Skill/BadLuckStreak/1",
@@ -448,9 +451,9 @@
}
]);
});
-test("attack-duration effects are only consumed when they participate in the calculation", () => {
+test("effects are only charged when they participate in the calculation", () => {
const defenderOutgoingBuff = {
...effect("active.hero.lethality.up", "defender", 100),
id: "defender-outgoing-buff",
duration: { type: "attack" as const, value: 1 }
@@ -458,9 +461,9 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);
- assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);
+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);
});
test("pct attack value evolution uses the effect use count for the current bucket value", () => {
const decayedAttackUp = {
@@ -480,9 +483,9 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [decayedAttackUp], { trace: true });
assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);
assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);
- assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));
+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));
});
test("pct turn value evolution starts decaying after the first active turn", () => {
const turnDecayAttackUp = {
@@ -505,9 +508,9 @@
assert.equal(firstTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);
});
-test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {
+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {
const weaker = {
...effect("active.hero.lethality.up", "attacker", 50),
id: "max-weaker",
duration: { type: "attack" as const, value: 3 },
@@ -532,9 +535,9 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [weaker, strongerButDecayed], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
- assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
});
test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {
const active = activateEffect(
Index: simulator/src/config.test.ts
===================================================================
--- simulator/src/config.test.ts prev run
+++ simulator/src/config.test.ts this run
@@ -70,8 +70,19 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);
});
+test("loadSimulatorConfig rejects legacy duration shape", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "target" },
+ duration: { type: "attack", value: 1 } as never
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /duration key type.*not supported/i);
+});
+
test("simulator config source does not reference legacy effect metadata names", () => {
const legacyEffectMetadataKey = ["effect", "op"].join("_");
const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");
@@ -165,9 +176,9 @@
const durationRoot = writeConfigWithTroopEffect(
{
type: "passive.attack.up",
value: 10,
- duration: { type: "round", value: 1 }
+ duration: { turns: { count: 1 } }
},
{ type: "battle_start" }
);
const evolvingRoot = writeConfigWithTroopEffect(
Index: simulator/src/config.ts
===================================================================
--- simulator/src/config.ts prev run
+++ simulator/src/config.ts this run
@@ -172,8 +172,9 @@
collectAmbiguousTurnTriggerSelectorDiagnostics(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId, diagnostics);
validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
+ validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);
if (!KNOWN_EFFECT_TYPES.has(type)) {
diagnostics.unsupportedEffects.push({
@@ -262,8 +263,47 @@
}
}
}
+function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ if (effect.duration === undefined) return;
+ const path = `${file}:${skillId}.${effectId}.duration`;
+ const duration = effect.duration as Record<string, unknown>;
+ for (const key of Object.keys(duration)) {
+ if (key !== "turns" && key !== "rounds" && key !== "attacks") {
+ throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);
+ }
+ }
+ for (const key of ["turns", "rounds", "attacks"] as const) {
+ const value = duration[key];
+ if (value === undefined) continue;
+ validateNativeEffectDurationAxis(value, `${path}.${key}`);
+ }
+}
+
+function validateNativeEffectDurationAxis(value: unknown, path: string): void {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`native effect duration axis must be an object at ${path}`);
+ }
+ const axis = value as Record<string, unknown>;
+ for (const key of Object.keys(axis)) {
+ if (key !== "count" && key !== "delay") {
+ throw new Error(`native effect duration axis key ${key} is not supported at ${path}; use count and/or delay`);
+ }
+ }
+ if (axis.count === undefined && axis.delay === undefined) {
+ throw new Error(`native effect duration axis must define count or delay at ${path}`);
+ }
+ if (axis.count !== undefined) validateIntegerRange(axis.count, 1, `${path}.count`);
+ if (axis.delay !== undefined) validateIntegerRange(axis.delay, 0, `${path}.delay`);
+}
+
+function validateIntegerRange(value: unknown, min: number, path: string): void {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {
+ throw new Error(`native effect duration ${path} must be an integer >= ${min}`);
+ }
+}
+
function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
const path = `${file}:${skillId}.${effectId}`;
if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {
throw new Error(`extra_skill_attack requires non-empty trigger_damage_jobs at ${path}`);
Index: simulator/src/damage.ts
===================================================================
--- simulator/src/damage.ts prev run
+++ simulator/src/damage.ts this run
@@ -11,9 +11,9 @@
} from "./types";
import { UNIT_TYPES } from "./types";
import { classifyEffectForJob } from "./classifier";
import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { currentEffectValuePct, isEffectActive } from "./effects";
+import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";
import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
import {
buildStaticDamageProfile,
type StaticDamageBucket,
@@ -55,22 +55,19 @@
}
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
+// Lean result of a single damage job: kills plus, only when tracing, the recording detail.
+// Usage charging happens via options.usedEffects (every mode); 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[];
- 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_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");
@@ -108,9 +105,9 @@
export function calculateDamageJob(
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean }
+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
// 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.
@@ -127,11 +124,11 @@
const buckets = needsTraceBuckets || !options.scratch ? createNumericDamageBuckets(needsTraceBuckets) : resetDamageScratch(options.scratch);
applyBucketValue(buckets, "troops.count", armyTerm);
applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
- const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];
- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];
- const consumedEffectIds = new Set<string>();
+ const appliedEffects: DamageEquationTrace["appliedEffects"] = [];
+ const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
const candidates: BucketCandidate[] = [];
const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
if (!isEffectActive(candidate.effect, job.round)) continue;
@@ -157,9 +154,9 @@
rejectedEffects.push({ effectId: effect.source.effectId ?? effect.id, reason: classification?.reason ?? classification?.kind ?? "not_bucket_effect" });
}
}
- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
const staticTraceEntries = [staticProfile.offense[job.attackerSide][job.attackerUnit], staticProfile.defense[job.defenderSide][job.defenderUnit]];
@@ -183,14 +180,10 @@
finalKills: kills
}
: undefined;
- const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];
-
return {
kills,
- consumedEffectIds: returnedConsumedEffectIds,
- appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,
appliedEffects: traceEnabled ? appliedEffects : undefined,
trace
};
}
@@ -200,9 +193,9 @@
buckets: NumericDamageBuckets,
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
for (const candidate of candidates) {
if (candidate.effect.sameEffectStacking === "max" && candidate.effect.stackingKey) {
@@ -215,14 +208,14 @@
} else {
maxGroups.set(key, { selected: candidate, candidates: [candidate] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
}
}
// Apply the selected candidate's value to its bucket and (in trace mode) record it; returns the
@@ -245,8 +238,10 @@
selected.effect.sameEffectStacking
);
if (appliedValuePct !== 0 && detail === "full") {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
+ kind: "modifier",
+ activeEffectId: selected.effect.id,
effectId: selected.effect.source.effectId ?? selected.effect.id,
bucket: selected.bucket,
valuePct: appliedValuePct,
source: sourceLabel(selected.effect),
@@ -266,13 +261,13 @@
buckets: NumericDamageBuckets,
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);
if (appliedValuePct !== 0) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ usedEffects.add(candidate.effect);
} else if (detail === "full") {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
}
}
@@ -283,14 +278,15 @@
buckets: NumericDamageBuckets,
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);
if (appliedValuePct !== 0) {
+ // The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ usedEffects.add(candidate.effect);
if (detail === "full" && candidate !== selected) {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
}
}
@@ -354,8 +350,10 @@
for (const [bucket, term] of Object.entries(entry.buckets) as Array<[StaticDamageBucket, StaticDamageProfileEntry["buckets"][StaticDamageBucket]]>) {
if (!bucket.startsWith("passive.") || !term) continue;
for (const contributor of term.contributors) {
appliedEffects.push({
+ kind: "modifier",
+ activeEffectId: contributor.effectId,
effectId: contributor.effectId,
bucket,
valuePct: contributor.valuePct,
source: contributor.source,
@@ -559,7 +557,4 @@
function pctFromFactor(factor: number): number {
return Number(((factor - 1) * 100).toFixed(12));
}
-function sourceLabel(effect: ActiveEffect): string {
- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
-}
Index: simulator/src/effects.ts
===================================================================
--- simulator/src/effects.ts prev run
+++ simulator/src/effects.ts this run
@@ -13,8 +13,9 @@
import { ALL_UNIT_MASK, unitMask } from "./types";
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
+type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
interface CompiledTriggerSelectors {
source: ParsedTriggerSelector;
target: ParsedTriggerSelector;
@@ -126,15 +127,21 @@
function skillStackingSourceKey(skill: ResolvedSkill): string {
return skill.heroName ?? skill.troopType ?? "global";
}
+export function sourceLabel(effect: ActiveEffect): string {
+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
+}
+
export function isEffectActive(effect: ActiveEffect, round: number): boolean {
if (round < effect.startRound) return false;
- if (effect.duration.type === "battle") return true;
- if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);
- return effect.uses < Math.max(1, effect.duration.value);
+ return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));
}
+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
+ return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");
+}
+
export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
const baseValue = Number(effect.valuePct ?? 0);
if (!Number.isFinite(baseValue)) return 0;
const evolution = effect.intent.value_evolution;
@@ -228,15 +235,46 @@
}
function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
if (!duration) return { type: "battle", value: 0 };
- const rawType = duration.type === "turn" ? "round" : duration.type;
- if (rawType === "round" || rawType === "attack" || rawType === "battle") {
- return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 0) };
- }
+ const namedConstraints = normalizeNamedDurationConstraints(duration);
+ if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };
return { type: "battle", value: 0 };
}
+function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {
+ return duration.constraints ?? [{ type: duration.type, count: duration.value }];
+}
+
+function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {
+ const delay = Math.max(0, constraint.delay ?? 0);
+ if (constraint.type === "battle") return true;
+ if (constraint.type === "round") {
+ const startRound = effect.createdRound + delay;
+ if (round < startRound) return false;
+ if (constraint.count === undefined) return true;
+ return round < startRound + Math.max(1, constraint.count);
+ }
+ if (constraint.count === undefined) return true;
+ return effect.uses < delay + Math.max(1, constraint.count);
+}
+
+function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {
+ const constraints: EffectDurationConstraint[] = [];
+ const turns = duration.turns ?? duration.rounds;
+ if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));
+ if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));
+ return constraints;
+}
+
+function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {
+ return {
+ type,
+ ...(value.count === undefined ? {} : { count: Number(value.count) }),
+ ...(value.delay === undefined ? {} : { delay: Number(value.delay) })
+ };
+}
+
function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {
return value === "max" ? "max" : "add";
}
Index: simulator/src/recorder.ts
===================================================================
--- simulator/src/recorder.ts prev run
+++ simulator/src/recorder.ts this run
@@ -1,5 +1,6 @@
import type {
+ AppliedEffect,
AttackIntent,
AttackOutcome,
BattleTrace,
DamageJob,
@@ -10,9 +11,9 @@
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
+ * does all simulation-affecting work itself (kills, counters, effect usage charging); 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.
@@ -21,16 +22,17 @@
*/
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;
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;
+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
readonly attacks: AttackOutcome[];
readonly trace: BattleTrace | undefined;
}
const NO_ATTACKS: AttackOutcome[] = [];
+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
export const NULL_RECORDER: BattleRecorder = {
capturesTrace: false,
recordCancelled() {},
@@ -61,9 +63,9 @@
this.trace = trace;
this.capturesTrace = trace !== undefined;
}
- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {
this.attacks.push({
jobId: `${intent.id}:cancelled`,
kind: "normal",
attackerSide: intent.attackerSide,
@@ -74,17 +76,15 @@
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,
+ appliedEffects,
cancelledBy: effectId,
cancelReason: reason
});
}
- recordDamageJob(job: DamageJob, result: DamageResult): void {
+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): 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;
}
@@ -102,15 +102,19 @@
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,
+ appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),
trace: result.trace
});
}
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void {
this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });
}
}
+
+function mergeAppliedEffects(applied: AppliedEffect[] | undefined, extra: AppliedEffect[] | undefined): AppliedEffect[] {
+ if (!applied?.length) return extra?.length ? extra : NO_APPLIED_EFFECTS;
+ if (!extra?.length) return applied;
+ return [...applied, ...extra];
+}
Index: simulator/src/simulator.test.ts
===================================================================
--- simulator/src/simulator.test.ts prev run
+++ simulator/src/simulator.test.ts this run
@@ -6,9 +6,9 @@
import { loadSimulatorConfig } from "./config";
import { createSeededRng, chancePasses } from "./effects";
import { applyHeroGenerationStats, resolveFighter } from "./resolve";
import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";
-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";
+import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";
test("runPrepared reuses the compiled input seed when no override is supplied", () => {
const config = loadSimulatorConfig();
const stats = {
@@ -256,9 +256,9 @@
buff: {
type: "active.hero.lethality.up",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
}
@@ -295,9 +295,9 @@
guard: {
type: "active.hero.defense.up",
value: 100,
units: { applies_to: "target", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -310,8 +310,62 @@
assert.equal(report?.skillActivations, 2);
assert.equal(report?.effectActivations, 2);
});
+test("attack-duration effects with a round cap expire at the end of their active turn even when unused", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 4,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DreamLancer: {
+ name: "DreamLancer",
+ troop_type: "lancer",
+ skills: {
+ EvenTurnAmbusher: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ order: {
+ type: "attack_order",
+ value: ["marksman", "infantry", "lancer"],
+ units: { applies_to: "lancer", applies_vs: "marksman" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ NightmareTrace: {
+ trigger: { type: "turn", every: 2, source: "lancer" },
+ effects: {
+ mark: {
+ type: "active.hero.defense.down",
+ value: 100,
+ units: { applies_to: "target", applies_vs: "lancer" },
+ duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");
+ const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));
+ assert.equal(round4LancerAttack?.defenderUnit, "marksman");
+ assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
{
@@ -415,9 +469,9 @@
boost: {
type: "active.hero.lethality.up",
value: 10,
units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
- duration: { type: "battle", value: 1 },
+ duration: {},
same_effect_stacking: "add"
}
}
}
@@ -471,9 +525,9 @@
assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
});
-test("attack-duration effects are consumed even when a normal attack is cancelled", () => {
+test("cancelled attacks report the winning control as an applied effect", () => {
const result = simulateBattle(
{
maxRounds: 1,
attacker: {
@@ -495,35 +549,91 @@
cancel: {
type: "no_attack",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
debuff: {
type: "active.hero.attack.up",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
const cancelledAttack = cancelled!;
- assert.equal(cancelledAttack.consumedEffectIds.length, 2);
- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));
- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));
+ assert.equal(cancelledAttack.appliedEffects.length, 1);
+ const controlEvent = cancelledAttack.appliedEffects[0];
+ assert.equal(controlEvent.kind, "control");
+ assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");
+ assert.ok(controlEvent.activeEffectId.includes(":cancel:"));
assert.deepEqual(cancelledAttack.counterDeltas, [
{ side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },
{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }
]);
});
+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
+ // Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is
+ // active. By default the cancelled attack charges the buff, so round 2 lands without it;
+ // with useEffectsOnNoAttack:false the buff survives to boost round 2.
+ const input = {
+ maxRounds: 2,
+ seed: "cancel-charge",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: { SelfStunned: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ }
+ };
+ const config = minimalConfig({
+ SelfStunned: {
+ name: "SelfStunned",
+ skills: {
+ StunAndBuff: {
+ trigger: { type: "battle_start" },
+ effects: {
+ stun: {
+ type: "no_attack",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1, delay: 1 } }
+ },
+ buff: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ const roundTwoKills = (options: SimulationOptions): number => {
+ const result = simulateBattle(input, config, options);
+ const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));
+ assert.notEqual(attack, undefined);
+ return attack!.kills;
+ };
+
+ const charged = roundTwoKills({});
+ const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });
+ assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);
+});
+
test("cancelled normal attacks advance attack counters for later frequency checks", () => {
const result = simulateBattle(
{
maxRounds: 4,
@@ -545,9 +655,9 @@
effects: {
cancel: {
type: "no_attack",
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -636,9 +746,9 @@
value: 100,
same_effect_stacking: "max",
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
}
@@ -650,11 +760,13 @@
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);
+ const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");
+ assert.equal(extraAttackEvents.length, 1);
+ assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);
assert.notEqual(roundTwoSkillOutcome, undefined);
- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);
+ assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
});
test("requires_effect is ignored by native simulator effect activation", () => {
const result = simulateBattle(
@@ -766,16 +878,16 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
hitMarksman: {
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -818,9 +930,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -860,9 +972,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -876,9 +988,9 @@
shield: {
type: "active.hero.defense.up",
value: 50,
units: { applies_to: "trigger.target", applies_vs: "trigger.source" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -918,16 +1030,16 @@
type: "extra_skill_attack",
value: 500,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
second: {
type: "extra_skill_attack",
value: 500,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1021,9 +1133,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source" } as never],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1060,9 +1172,9 @@
hitAgain: {
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1097,9 +1209,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1144,9 +1256,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1160,9 +1272,9 @@
cancel: {
type: "no_attack",
value: 100,
units: { applies_to: "enemy.marksman", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1172,9 +1284,9 @@
);
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
- assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);
+ assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
assert.deepEqual(skillJobsByRound, [0, 1]);
assert.deepEqual(result.extraSkillAttackJobsByEffect, { hitAgain: 1 });
@@ -1204,9 +1316,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
}
@@ -1247,9 +1359,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
},
RecursiveGuard: {
@@ -1259,9 +1371,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1303,9 +1415,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
}
@@ -1381,9 +1493,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1421,9 +1533,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: ["lancer"] },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1464,9 +1576,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
},
SkillDamageBoost: {
@@ -1475,9 +1587,9 @@
boost: {
type: "type.skill.damage.up",
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
}
@@ -1518,9 +1630,9 @@
down: {
type: "active.hero.lethality.down",
value: 50,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
}
@@ -1561,9 +1673,9 @@
down: {
type: "active.hero.lethality.down",
value: 50,
units: { applies_to: "trigger.target", applies_vs: "any" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
}
@@ -1778,9 +1890,9 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
}
@@ -1793,9 +1905,9 @@
effects: {
pause: {
type: "no_attack",
units: { applies_to: "enemy.marksman", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
@@ -1937,16 +2049,16 @@
value: 100,
same_effect_stacking,
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "turn", value: 2 }
+ duration: { turns: { count: 2 } }
}
: {
type,
value: 100,
same_effect_stacking,
units: { applies_to: ["infantry"], applies_vs: "any" },
- duration: { type: "turn", value: 2 }
+ duration: { turns: { count: 2 } }
};
return minimalConfig({
[heroName]: {
name: heroName,
Index: simulator/src/simulator.ts
===================================================================
--- simulator/src/simulator.ts prev run
+++ simulator/src/simulator.ts this run
@@ -1,6 +1,10 @@
import type {
ActiveEffect,
+ AppliedControlEffect,
+ AppliedEffect,
+ AppliedExtraAttackEffect,
+ AppliedOrderEffect,
AttackIntent,
AttackOutcome,
BearBattleResult,
BattleInput,
@@ -26,13 +30,15 @@
activateEffect,
chancePasses,
createSeededRng,
currentEffectValuePct,
+ hasAttackDurationConstraint,
isEffectActive,
oppositeSide,
parseTriggerSelector,
sideForTriggerRelation,
skillMatchesTrigger,
+ sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
import { bucketCandidatesForJob, createEffectIndex, indexEffect, pruneEffectIndex, removeStaticProfileBucketEffects, type EffectIndex } from "./effectIndex";
@@ -48,9 +54,11 @@
interface Runtime {
activeEffects: ActiveEffect[];
effectIndex: EffectIndex;
- effectById: Map<string, ActiveEffect>;
+ // Per-job scratch: effects that affected the job being calculated; drained by
+ // chargeUsedEffects (uses += 1 each) after every job in every mode.
+ usedEffects: Set<ActiveEffect>;
staticDamageProfile?: StaticDamageProfile;
damageScratch: DamageScratch;
rng: Rng;
skills: RuntimeSkills;
@@ -79,9 +87,10 @@
}
interface ExtraSkillJobsResult {
jobs: DamageJob[];
- consumedEffectIds: string[];
+ usedEffects: ActiveEffect[];
+ appliedEffects?: AppliedExtraAttackEffect[];
}
interface BattleRun {
fighters: Record<SideId, ResolvedFighter>;
@@ -221,9 +230,9 @@
removeStaticProfileBucketEffects(effectIndex);
return {
activeEffects,
effectIndex,
- effectById: new Map(activeEffects.map((e) => [e.id, e])),
+ usedEffects: new Set(),
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
rng,
skills: template.skills,
@@ -298,8 +307,12 @@
): BattleRun {
const mode = options.mode ?? "standard";
const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));
const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
+ const useEffectsOnCancel = {
+ dodge: options.useEffectsOnDodge ?? true,
+ no_attack: options.useEffectsOnNoAttack ?? true
+ };
let rounds = 0;
let score = 0;
for (let round = 1; round <= maxRounds; round += 1) {
@@ -308,9 +321,11 @@
const roundStartTroops = snapshotTroops(fighters);
expireInactive(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
- const intents = resolveAttackIntents(round, runtime, roundStartTroops);
+ // Trace-only: battle_order applied events keyed by the intent they ordered.
+ const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;
+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);
const allJobs: DamageJob[] = []; // for recorder
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
@@ -323,64 +338,73 @@
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, runtime.effectIndex);
- cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });
- consumeEffects(runtime, consumedEffectIds);
+ if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
+ cancelled.push({
+ intent,
+ effectId: control.effect.id,
+ reason: control.reason,
+ appliedEffects: recorder.capturesTrace
+ ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
+ : NO_APPLIED_EFFECTS
+ });
} else {
pendingNormalJobs.push(job);
}
}
// 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
+ // chargeUsedEffects 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
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
});
- 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);
+ chargeUsedEffects(runtime);
- const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops);
- if (extraSkill.consumedEffectIds.length > 0) {
- normalResult.consumedEffectIds = [...normalResult.consumedEffectIds, ...extraSkill.consumedEffectIds];
- consumeEffects(runtime, extraSkill.consumedEffectIds);
- }
+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);
+ for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;
+ results.push({
+ job,
+ result: normalResult,
+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)
+ });
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
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
});
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);
+ chargeUsedEffects(runtime);
}
}
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);
+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);
+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.extraAppliedEffects);
recorder.recordRound(round, roundStartTroops, intents, allJobs);
}
const winner = winnerFor(fighters) ?? "draw";
@@ -398,16 +422,41 @@
interface CancelledAttack {
intent: AttackIntent;
effectId: string;
reason: "dodge" | "no_attack";
- consumedEffectIds: string[];
+ appliedEffects: AppliedEffect[];
}
interface DamageJobResult {
job: DamageJob;
result: DamageResult;
+ extraAppliedEffects?: AppliedEffect[];
}
+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
+
+function appendedEvent(order: AppliedOrderEffect | undefined, event: AppliedEffect): AppliedEffect[] {
+ return order ? [order, event] : [event];
+}
+
+function appendedEvents(order: AppliedOrderEffect | undefined, events: AppliedEffect[] | undefined): AppliedEffect[] | undefined {
+ if (!order) return events;
+ return events ? [order, ...events] : [order];
+}
+
+function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {
+ return {
+ activeEffectId: effect.id,
+ effectId: effect.source.effectId ?? effect.id,
+ source: sourceLabel(effect),
+ sourceSide: effect.ownerSide
+ };
+}
+
+function appliedControlEvent(control: Control): AppliedControlEffect {
+ return { kind: "control", ...appliedEffectBase(control.effect), reason: control.reason };
+}
+
function triggerRoundStartSkills(
round: number,
runtime: Runtime,
roundStartTroops: DamageJob["roundStartTroops"]
@@ -528,9 +577,9 @@
}
return {
activeEffects: [],
effectIndex: createEffectIndex(),
- effectById: new Map(),
+ usedEffects: new Set(),
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
rng,
skills,
@@ -561,9 +610,8 @@
}
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 {
@@ -640,22 +688,29 @@
function resolveAttackIntents(
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ orderEvents?: Map<string, AppliedOrderEffect>
): AttackIntent[] {
const intents: AttackIntent[] = [];
for (const side of ["attacker", "defender"] as SideId[]) {
const defenderSide = oppositeSide(side);
let orderIndex = 0;
for (const attackerUnit of UNIT_TYPES) {
if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);
+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);
+ const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);
if (!defenderUnit) continue;
+ const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;
+ if (ordered) {
+ ordered.effect.uses += 1;
+ orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });
+ }
const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];
intents.push({
- id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,
+ id: intentId,
round,
source: "normal",
attackerSide: side,
attackerUnit,
@@ -672,27 +727,38 @@
}
return intents;
}
+// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but
+// does not charge them: no real attack intent is being ordered.
function chooseDefenderUnit(
attackerUnit: UnitType,
attackerSide: SideId,
defenderSide: SideId,
roundStartTroops: DamageJob["roundStartTroops"],
effectIndex: EffectIndex,
round: number
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;
- return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;
+ return firstLivingUnit(order, defenderSide, roundStartTroops);
}
-function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {
+function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {
+ return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
+}
+
+function orderFromEffects(
+ attackerUnit: UnitType,
+ attackerSide: SideId,
+ index: EffectIndex,
+ round: number
+): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
if (Array.isArray(effect.intent.value)) {
try {
- return effect.intent.value.map((value) => normalizeUnitType(String(value)));
+ return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
} catch {
return undefined;
}
}
@@ -739,12 +805,14 @@
function extraSkillJobs(
normalAttack: DamageJob,
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ capturesTrace: boolean
): ExtraSkillJobsResult {
const jobs: DamageJob[] = [];
- const consumedEffectIds: string[] = [];
+ const usedEffects: ActiveEffect[] = [];
+ let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
);
@@ -783,12 +851,21 @@
}
definitionIndex += 1;
}
if (jobs.length > firstJobIndex) {
- consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));
+ // The whole stacking group is charged whenever the selected effect spawned jobs,
+ // regardless of duration constraints (extra attacks always deplete per firing).
+ usedEffects.push(...effectGroup.effects);
+ if (capturesTrace) {
+ (appliedEffects ??= []).push({
+ kind: "extra_attack",
+ ...appliedEffectBase(effect),
+ spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)
+ });
+ }
}
}
- return { jobs, consumedEffectIds };
+ return { jobs, usedEffects, appliedEffects };
}
function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
const selected: ExtraAttackEffectGroup[] = [];
@@ -925,28 +1002,32 @@
runtime.counters.received[job.defenderSide][job.defenderUnit] += 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);
+// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
+// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
+function chargeUsedEffects(runtime: Runtime): void {
+ if (runtime.usedEffects.size === 0) return;
+ for (const effect of runtime.usedEffects) effect.uses += 1;
+ runtime.usedEffects.clear();
+}
+
+// A cancelled attack still charges the attacker's attack-constrained effects (the attack
+// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
+function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {
+ const used = runtime.usedEffects;
+ for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {
+ if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);
}
- for (const effect of index.controls) {
- if (effect.duration.type !== "attack") continue;
+ for (const effect of runtime.effectIndex.controls) {
+ if (!hasAttackDurationConstraint(effect)) continue;
const classification = classifyEffectForJob(effect, job);
- if (classification?.kind === "control") ids.push(effect.id);
+ if (classification?.kind === "control") used.add(effect);
}
- return ids;
+ used.add(winningControl);
+ chargeUsedEffects(runtime);
}
-function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {
- for (const consumed of consumedEffectIds) {
- const effect = runtime.effectById.get(consumed);
- if (effect) 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/staticDamageProfile.ts
===================================================================
--- simulator/src/staticDamageProfile.ts prev run
+++ simulator/src/staticDamageProfile.ts this run
@@ -1,8 +1,8 @@
import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
-import { currentEffectValuePct } from "./effects";
+import { currentEffectValuePct, sourceLabel } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
totalPct?: number;
@@ -89,15 +89,11 @@
}
const duration = effect.duration;
if (duration === undefined) return;
- const durationType = duration.type ?? "battle";
- if (durationType !== "battle") {
+ if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {
throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
}
- if (duration.delay !== undefined && duration.delay !== 0) {
- throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);
- }
}
export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {
return (STATIC_BUCKET_SET as Set<string>).has(bucket);
@@ -265,11 +261,8 @@
function emptyStats(): StatBlock {
return { attack: 0, defense: 0, lethality: 0, health: 0 };
}
-function sourceLabel(effect: ActiveEffect): string {
- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
-}
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);
Index: simulator/src/testcases.ts
===================================================================
--- simulator/src/testcases.ts prev run
+++ simulator/src/testcases.ts this run
@@ -3,9 +3,8 @@
import { fileURLToPath } from "node:url";
import {
loadCalibrationComparison,
- readCalibrationCase,
type CalibrationCaseComparison,
type SampleStats,
sampleStats
} from "./calibration";
@@ -350,9 +349,8 @@
return;
}
const stats = execution.simulatorStats;
const gameResult = (entry as { game_report_result?: unknown }).game_report_result;
- const calibration = readCalibrationCase(comparison, reportFile, testcaseId, { index });
const initialTroops = totalInputTroops(preparedCase.input!.attacker) + totalInputTroops(preparedCase.input!.defender);
const simulatorDistribution = { n: stats.n, mu: stats.mu, sigma: stats.sigma };
const gameDistribution = distributionFromGameResult(gameResult);
let game = gameDistribution
@@ -376,17 +374,8 @@
thresholds: comparison.thresholds
})
: undefined;
if (gameStatAdjustment) game = gameStatAdjustment.adjusted;
- const baseline = calibration?.nSim !== undefined && calibration.muSim !== undefined && calibration.sigmaSim !== undefined
- ? compareOutcomeDistribution({
- candidate: simulatorDistribution,
- reference: { n: calibration.nSim, mu: calibration.muSim, sigma: calibration.sigmaSim },
- initialTroops,
- deterministic: result.randomness.deterministic,
- thresholds: comparison.thresholds
- })
- : null;
detail.result = result;
detail.deterministic = execution.deterministic;
detail.sampleCount = execution.sampleCount;
@@ -395,26 +384,22 @@
detail.simulatorSampleOutcomes = execution.simulatorSampleOutcomes;
detail.simulatorSampleDeltas = execution.simulatorSampleDeltas;
detail.gameStatAdjustment = gameStatAdjustment ? statAdjustmentForReport(gameStatAdjustment) : undefined;
detail.gameResult = gameResult;
- detail.calibration = calibration;
detail.visibility = visibilityFromResult(result);
detail.diagnostics.push(...execution.diagnostics);
if (!game) {
report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "game_comparison", reason: "Missing game_report_result" });
}
- if (!baseline) {
- report.warnings.push({ file: reportFile, testcase_id: testcaseId, idx: index, stage: "baseline_comparison", reason: "No matching baseline snapshot row" });
- }
report.counts.executed += 1;
report.testcases[preparedCase.key!] = {
file: reportFile,
testcase_id: testcaseId,
idx: index,
deterministic: execution.deterministic,
sampleCount: execution.sampleCount,
game,
- baseline,
+ baseline: null,
...(gameStatAdjustment ? { gameStatAdjustment: statAdjustmentForReport(gameStatAdjustment) } : {})
};
}
@@ -631,9 +616,8 @@
}
export function applyComparisonQValues(report: Pick<TestcaseRunReport, "testcases">): void {
applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.game).filter((value): value is ParityComparisonMetrics => !!value));
- applyBenjaminiHochberg(Object.values(report.testcases).map((entry) => entry.baseline).filter((value): value is ParityComparisonMetrics => !!value));
}
export function assignDetailArtifactPaths(report: TestcaseRunReport, artifactRoot: string): void {
report.artifactRoot = artifactRoot;
Index: simulator/src/types.ts
===================================================================
--- simulator/src/types.ts prev run
+++ simulator/src/types.ts this run
@@ -77,8 +77,9 @@
export interface EffectDuration {
type: "battle" | "round" | "attack";
value: number;
delay?: number;
+ constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;
}
export interface EffectIntentDefinition {
id: string;
@@ -86,9 +87,13 @@
value?: unknown;
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
- duration?: { type?: string; value?: number; delay?: number };
+ duration?: {
+ turns?: { count?: number; delay?: number };
+ rounds?: { count?: number; delay?: number };
+ attacks?: { count?: number; delay?: number };
+ };
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
@@ -171,8 +176,12 @@
export type SimulationMode = "fast" | "standard" | "trace";
export interface SimulationOptions {
mode?: SimulationMode;
+ // Whether a dodged / no_attack'd attack still charges (uses += 1) the attacker's
+ // attack-constrained effects, as the game does. Default true.
+ useEffectsOnDodge?: boolean;
+ useEffectsOnNoAttack?: boolean;
}
export interface ResolvedTroopLine {
id: string;
@@ -244,8 +253,12 @@
triggerDamageJobs?: TriggerDamageJobDefinition[];
createdRound: number;
startRound: number;
duration: EffectDuration;
+ // Times this instance affected battle mechanics (damage bucket applied, control fired,
+ // attack ordered, extra attack spawned). Attack duration constraints and step:"attack"
+ // value evolution read it; cancelled attacks still charge attack-constrained effects
+ // unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
stackingKey?: string;
sameEffectStacking: SameEffectStacking;
}
@@ -309,24 +322,51 @@
roundStartTroops: Record<SideId, Record<UnitType, number>>;
armyTerm: number;
atomicBuckets: Record<string, DamageBucketTrace>;
aggregationGroups: Record<string, DamageAggregationGroupTrace>;
- appliedEffects: AppliedEffectTrace[];
+ appliedEffects: AppliedModifierEffect[];
rejectedEffects: Array<{ effectId: string; reason: string }>;
rawDamage: number;
finalKills: number;
}
-export interface AppliedEffectTrace {
+// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.
+// Events are built only in trace mode; the uses counter is charged in every mode.
+interface AppliedEffectBase {
+ // ActiveEffect.id — the runtime instance. Static-profile/input-stat contributors have no
+ // ActiveEffect and use the config-level effectId here too.
+ activeEffectId: string;
+ // Config-level id (source.effectId ?? ActiveEffect.id).
effectId: string;
- bucket: string;
- valuePct: number;
source: string;
sourceSide?: SideId;
+}
+
+export interface AppliedModifierEffect extends AppliedEffectBase {
+ kind: "modifier";
+ bucket: string;
+ valuePct: number;
stackingKey?: string;
sameEffectStacking?: SameEffectStacking;
}
+export interface AppliedControlEffect extends AppliedEffectBase {
+ kind: "control";
+ reason: "dodge" | "no_attack";
+}
+
+export interface AppliedOrderEffect extends AppliedEffectBase {
+ kind: "battle_order";
+ chosenTarget: UnitType;
+}
+
+export interface AppliedExtraAttackEffect extends AppliedEffectBase {
+ kind: "extra_attack";
+ spawnedJobIds: string[];
+}
+
+export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;
+
export interface AttackOutcome {
jobId: string;
kind: DamageKind;
sourceEffectId?: string;
@@ -336,11 +376,9 @@
defenderSide: SideId;
defenderUnit: UnitType;
kills: number;
counterDeltas: CounterDelta[];
- appliedEffectIds: string[];
- appliedEffects: AppliedEffectTrace[];
- consumedEffectIds: string[];
+ appliedEffects: AppliedEffect[];
cancelledBy?: string;
cancelReason?: "dodge" | "no_attack";
trace?: DamageEquationTrace;
}
Show Raw Dirty State Patch (vs Clean Baseline)
diff --git a/simulator/config/hero_definitions/Ahmose.json b/simulator/config/hero_definitions/Ahmose.json
--- a/simulator/config/hero_definitions/Ahmose.json
+++ b/simulator/config/hero_definitions/Ahmose.json
@@ -19,8 +19,9 @@
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
},
"ViperFormation/2": {
@@ -39,9 +40,10 @@
]
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
},
@@ -60,9 +62,10 @@
]
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -113,8 +116,9 @@
]
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
},
"BladeOfLight/2": {
@@ -130,8 +134,9 @@
"applies_to": "target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "add"
}
diff --git a/simulator/config/hero_definitions/Alonso.json b/simulator/config/hero_definitions/Alonso.json
--- a/simulator/config/hero_definitions/Alonso.json
+++ b/simulator/config/hero_definitions/Alonso.json
@@ -31,8 +31,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -64,9 +65,10 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -99,8 +101,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
diff --git a/simulator/config/hero_definitions/Bahiti.json b/simulator/config/hero_definitions/Bahiti.json
--- a/simulator/config/hero_definitions/Bahiti.json
+++ b/simulator/config/hero_definitions/Bahiti.json
@@ -47,8 +47,9 @@
"applies_to": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Bradley.json b/simulator/config/hero_definitions/Bradley.json
--- a/simulator/config/hero_definitions/Bradley.json
+++ b/simulator/config/hero_definitions/Bradley.json
@@ -76,8 +76,9 @@
30
],
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Gordon.json b/simulator/config/hero_definitions/Gordon.json
--- a/simulator/config/hero_definitions/Gordon.json
+++ b/simulator/config/hero_definitions/Gordon.json
@@ -26,8 +26,9 @@
]
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
},
"VenomInfusion/2": {
@@ -43,8 +44,9 @@
"applies_to": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -71,8 +73,9 @@
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
},
@@ -90,8 +93,9 @@
"applies_vs": "self.any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -117,8 +121,9 @@
"applies_to": "enemy.infantry"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
},
@@ -135,8 +140,9 @@
"applies_to": "enemy.marksman"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Greg.json b/simulator/config/hero_definitions/Greg.json
--- a/simulator/config/hero_definitions/Greg.json
+++ b/simulator/config/hero_definitions/Greg.json
@@ -31,8 +31,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 3
+ "turns": {
+ "count": 3
+ }
}
}
}
@@ -65,8 +66,9 @@
"applies_vs": "self.any"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
}
}
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
@@ -24,8 +24,9 @@
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -51,8 +52,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -60,7 +62,7 @@
"target": "effect.applies_vs"
}
]
- },
+ },
"AirDominance/2": {
"type": "active.hero.damageTaken.up",
"value": [
@@ -74,9 +76,10 @@
"applies_to": "target"
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "attacks": {
+ "count": 1,
+ "delay": 1
+ }
}
}
}
@@ -104,9 +107,10 @@
]
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "attacks": {
+ "count": 1,
+ "delay": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/hero_definitions/Hector.json b/simulator/config/hero_definitions/Hector.json
--- a/simulator/config/hero_definitions/Hector.json
+++ b/simulator/config/hero_definitions/Hector.json
@@ -26,8 +26,9 @@
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -55,8 +56,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 10
+ "attacks": {
+ "count": 10
+ }
},
"value_evolution": {
"type": "pct_decay",
@@ -80,8 +82,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 10
+ "attacks": {
+ "count": 10
+ }
},
"value_evolution": {
"type": "pct_decay",
@@ -118,8 +121,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/hero_definitions/Jeronimo.json b/simulator/config/hero_definitions/Jeronimo.json
--- a/simulator/config/hero_definitions/Jeronimo.json
+++ b/simulator/config/hero_definitions/Jeronimo.json
@@ -60,8 +60,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Lynn.json b/simulator/config/hero_definitions/Lynn.json
--- a/simulator/config/hero_definitions/Lynn.json
+++ b/simulator/config/hero_definitions/Lynn.json
@@ -26,8 +26,9 @@
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
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
@@ -29,8 +29,9 @@
"applies_to": "target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -63,8 +64,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
@@ -99,8 +101,9 @@
50
],
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Molly.json b/simulator/config/hero_definitions/Molly.json
--- a/simulator/config/hero_definitions/Molly.json
+++ b/simulator/config/hero_definitions/Molly.json
@@ -29,8 +29,9 @@
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -62,8 +63,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/hero_definitions/Natalia.json b/simulator/config/hero_definitions/Natalia.json
--- a/simulator/config/hero_definitions/Natalia.json
+++ b/simulator/config/hero_definitions/Natalia.json
@@ -29,8 +29,9 @@
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Norah.json b/simulator/config/hero_definitions/Norah.json
--- a/simulator/config/hero_definitions/Norah.json
+++ b/simulator/config/hero_definitions/Norah.json
@@ -74,8 +74,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
@@ -109,8 +110,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 2
+ "turns": {
+ "count": 2
+ }
}
},
"Momentum/2": {
@@ -123,9 +125,10 @@
25
],
"duration": {
- "type": "turn",
- "value": 2,
- "delay": 1
+ "turns": {
+ "count": 2,
+ "delay": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Philly.json b/simulator/config/hero_definitions/Philly.json
--- a/simulator/config/hero_definitions/Philly.json
+++ b/simulator/config/hero_definitions/Philly.json
@@ -58,8 +58,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -96,8 +97,9 @@
"applies_to": "all"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
diff --git a/simulator/config/hero_definitions/Reina.json b/simulator/config/hero_definitions/Reina.json
--- a/simulator/config/hero_definitions/Reina.json
+++ b/simulator/config/hero_definitions/Reina.json
@@ -48,8 +48,9 @@
"applies_to": "trigger"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -84,8 +85,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
diff --git a/simulator/config/hero_definitions/Renee.json b/simulator/config/hero_definitions/Renee.json
--- a/simulator/config/hero_definitions/Renee.json
+++ b/simulator/config/hero_definitions/Renee.json
@@ -25,9 +25,13 @@
"applies_vs": "lancer"
},
"duration": {
- "type": "attack",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ },
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -55,9 +59,10 @@
"applies_vs": "lancer"
},
"duration": {
- "type": "turn",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
@@ -85,9 +90,10 @@
"applies_vs": "any"
},
"duration": {
- "type": "turn",
- "value": 1,
- "delay": 1
+ "turns": {
+ "count": 1,
+ "delay": 1
+ }
},
"same_effect_stacking": "max"
}
diff --git a/simulator/config/hero_definitions/Wayne.json b/simulator/config/hero_definitions/Wayne.json
--- a/simulator/config/hero_definitions/Wayne.json
+++ b/simulator/config/hero_definitions/Wayne.json
@@ -25,8 +25,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"same_effect_stacking": "max",
"trigger_damage_jobs": [
@@ -60,8 +61,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -86,8 +88,9 @@
"applies_vs": "any"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -127,8 +130,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/config/troop_skills.json b/simulator/config/troop_skills.json
--- a/simulator/config/troop_skills.json
+++ b/simulator/config/troop_skills.json
@@ -97,8 +97,9 @@
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -206,8 +207,9 @@
]
},
"duration": {
- "type": "turn",
- "value": 1
+ "turns": {
+ "count": 1
+ }
}
}
}
@@ -249,8 +251,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -290,8 +293,9 @@
"applies_vs": "trigger.source"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
}
}
}
@@ -354,8 +358,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -403,8 +408,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
@@ -470,8 +476,9 @@
"applies_vs": "trigger.target"
},
"duration": {
- "type": "attack",
- "value": 1
+ "attacks": {
+ "count": 1
+ }
},
"trigger_damage_jobs": [
{
diff --git a/simulator/src/classifierDamage.test.ts b/simulator/src/classifierDamage.test.ts
--- a/simulator/src/classifierDamage.test.ts
+++ b/simulator/src/classifierDamage.test.ts
@@ -101,7 +101,9 @@
}
const staticDamageProfile = options.staticDamageProfile ?? buildStaticDamageProfile(fighters, effects);
removeStaticProfileBucketEffects(effectIndex);
- return calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile });
+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
+ const result = calculateDamageJob(damageJob, fighters, effects, { ...options, effectIndex, staticDamageProfile, usedEffects });
+ return { ...result, usedEffectIds: [...usedEffects].map((usedEffect) => usedEffect.id) };
}
test("classifier routes up/down effects into neutral atomic buckets", () => {
@@ -412,7 +414,7 @@
assert.equal(skillOutcome.trace?.atomicBuckets["type.skill.damage.up"].totalPct, 100);
});
-test("attack-duration bucket effects are consumed by the applicable attack job", () => {
+test("attack-duration bucket effects are charged by the applicable attack job", () => {
const oneAttackEffect = {
...effect("active.hero.attack.up", "attacker", 100),
id: "attack-up-active",
@@ -421,11 +423,11 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [oneAttackEffect], { trace: true });
- assert.ok(outcome.consumedEffectIds.includes("attack-up-active"));
+ assert.ok(outcome.usedEffectIds.includes("attack-up-active"));
assert.equal(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 100);
});
-test("turn-duration bucket effects are visible on the attack outcome without being consumed", () => {
+test("turn-duration bucket effects are charged and visible on the attack outcome", () => {
const turnEffect = {
...effect("active.hero.defense.down", "defender", 30),
id: "bad-luck-like",
@@ -435,10 +437,11 @@
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.usedEffectIds.includes("bad-luck-like"));
assert.deepEqual(outcome.appliedEffects, [
{
+ kind: "modifier",
+ activeEffectId: "bad-luck-like",
effectId: "BadLuckStreak/1",
bucket: "active.hero.defense.down",
valuePct: 30,
@@ -449,7 +452,7 @@
]);
});
-test("attack-duration effects are only consumed when they participate in the calculation", () => {
+test("effects are only charged when they participate in the calculation", () => {
const defenderOutgoingBuff = {
...effect("active.hero.lethality.up", "defender", 100),
id: "defender-outgoing-buff",
@@ -459,7 +462,7 @@
const outcome = calculateIndexedDamageJob(job, simpleFighters(), [defenderOutgoingBuff], { trace: true });
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 0);
- assert.equal(outcome.consumedEffectIds.includes("defender-outgoing-buff"), false);
+ assert.equal(outcome.usedEffectIds.includes("defender-outgoing-buff"), false);
});
test("pct attack value evolution uses the effect use count for the current bucket value", () => {
@@ -481,7 +484,7 @@
assert.equal(Number(outcome.trace?.atomicBuckets["active.hero.attack.up"].totalPct?.toFixed(4)), 72.25);
assert.equal(Number((outcome.kills / baseline.kills).toFixed(4)), 1.7225);
- assert.ok(outcome.consumedEffectIds.includes("decayed-attack-up"));
+ assert.ok(outcome.usedEffectIds.includes("decayed-attack-up"));
});
test("pct turn value evolution starts decaying after the first active turn", () => {
@@ -506,7 +509,7 @@
assert.equal(secondTurn.trace?.atomicBuckets["active.hero.attack.up"].totalPct, 85);
});
-test("max-stacked attack-duration effects consume the whole eligible group and output only the max current value", () => {
+test("max-stacked attack-duration effects charge the whole eligible group and output only the max current value", () => {
const weaker = {
...effect("active.hero.lethality.up", "attacker", 50),
id: "max-weaker",
@@ -533,7 +536,7 @@
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].totalPct, 50);
assert.equal(outcome.trace?.atomicBuckets["active.hero.lethality.up"].contributors.length, 1);
- assert.deepEqual(new Set(outcome.consumedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
+ assert.deepEqual(new Set(outcome.usedEffectIds), new Set(["max-weaker", "max-stronger-decayed"]));
});
test('applies_vs "trigger.source" resolves to the trigger source when gating a concrete target', () => {
diff --git a/simulator/src/config.test.ts b/simulator/src/config.test.ts
--- a/simulator/src/config.test.ts
+++ b/simulator/src/config.test.ts
@@ -71,6 +71,17 @@
assert.throws(() => loadSimulatorConfigFromDir(root), /legacy field/i);
});
+test("loadSimulatorConfig rejects legacy duration shape", () => {
+ const root = writeConfigWithTroopEffect({
+ type: "active.hero.lethality.up",
+ value: 10,
+ units: { applies_to: "trigger.source", applies_vs: "target" },
+ duration: { type: "attack", value: 1 } as never
+ });
+
+ assert.throws(() => loadSimulatorConfigFromDir(root), /duration key type.*not supported/i);
+});
+
test("simulator config source does not reference legacy effect metadata names", () => {
const legacyEffectMetadataKey = ["effect", "op"].join("_");
const source = readFileSync(fileURLToPath(new URL("./config.ts", import.meta.url)), "utf8");
@@ -166,7 +177,7 @@
{
type: "passive.attack.up",
value: 10,
- duration: { type: "round", value: 1 }
+ duration: { turns: { count: 1 } }
},
{ type: "battle_start" }
);
diff --git a/simulator/src/config.ts b/simulator/src/config.ts
--- a/simulator/src/config.ts
+++ b/simulator/src/config.ts
@@ -173,6 +173,7 @@
validateBattleStartEffectSelectors(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectUnits(effect as EffectIntentDefinition, file, skillId, effectId);
validateNativeEffectValue(effect as EffectIntentDefinition, file, skillId, effectId);
+ validateNativeEffectDuration(effect as EffectIntentDefinition, file, skillId, effectId);
assertStaticPassiveEffectDefinition(skill.trigger, effect as EffectIntentDefinition, file, skillId, effectId);
if (type === "extra_skill_attack") validateExtraSkillAttackEffect(effect as EffectIntentDefinition, file, skillId, effectId);
if (!KNOWN_EFFECT_TYPES.has(type)) {
@@ -263,6 +264,45 @@
}
}
+function validateNativeEffectDuration(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
+ if (effect.duration === undefined) return;
+ const path = `${file}:${skillId}.${effectId}.duration`;
+ const duration = effect.duration as Record<string, unknown>;
+ for (const key of Object.keys(duration)) {
+ if (key !== "turns" && key !== "rounds" && key !== "attacks") {
+ throw new Error(`native effect duration key ${key} is not supported at ${path}; use turns and/or attacks`);
+ }
+ }
+ for (const key of ["turns", "rounds", "attacks"] as const) {
+ const value = duration[key];
+ if (value === undefined) continue;
+ validateNativeEffectDurationAxis(value, `${path}.${key}`);
+ }
+}
+
+function validateNativeEffectDurationAxis(value: unknown, path: string): void {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`native effect duration axis must be an object at ${path}`);
+ }
+ const axis = value as Record<string, unknown>;
+ for (const key of Object.keys(axis)) {
+ if (key !== "count" && key !== "delay") {
+ throw new Error(`native effect duration axis key ${key} is not supported at ${path}; use count and/or delay`);
+ }
+ }
+ if (axis.count === undefined && axis.delay === undefined) {
+ throw new Error(`native effect duration axis must define count or delay at ${path}`);
+ }
+ if (axis.count !== undefined) validateIntegerRange(axis.count, 1, `${path}.count`);
+ if (axis.delay !== undefined) validateIntegerRange(axis.delay, 0, `${path}.delay`);
+}
+
+function validateIntegerRange(value: unknown, min: number, path: string): void {
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {
+ throw new Error(`native effect duration ${path} must be an integer >= ${min}`);
+ }
+}
+
function validateExtraSkillAttackEffect(effect: EffectIntentDefinition, file: string, skillId: string, effectId: string): void {
const path = `${file}:${skillId}.${effectId}`;
if (!Array.isArray(effect.trigger_damage_jobs) || effect.trigger_damage_jobs.length === 0) {
diff --git a/simulator/src/damage.ts b/simulator/src/damage.ts
--- a/simulator/src/damage.ts
+++ b/simulator/src/damage.ts
@@ -12,7 +12,7 @@
import { UNIT_TYPES } from "./types";
import { classifyEffectForJob } from "./classifier";
import { ATOMIC_BUCKETS, BUCKET_DEFINITIONS, type AtomicBucket } from "./damageBuckets";
-import { currentEffectValuePct, isEffectActive } from "./effects";
+import { currentEffectValuePct, isEffectActive, sourceLabel } from "./effects";
import { bucketCandidatesForJob, type EffectIndex } from "./effectIndex";
import {
buildStaticDamageProfile,
@@ -56,20 +56,17 @@
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
+// Lean result of a single damage job: kills plus, only when tracing, the recording detail.
+// Usage charging happens via options.usedEffects (every mode); 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[];
- 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_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));
@@ -109,7 +106,7 @@
job: DamageJob,
fighters: Record<SideId, ResolvedFighter>,
activeEffects: ActiveEffect[],
- options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean }
+ options: { trace?: boolean; effectIndex: EffectIndex; staticDamageProfile?: StaticDamageProfile; scratch?: DamageScratch; capToDefenderTroops?: boolean; usedEffects?: Set<ActiveEffect> }
): DamageResult {
if (!options?.effectIndex) throw new Error("calculateDamageJob requires an effectIndex");
// The damage math is one path; `trace` only decides whether we also capture the (expensive)
@@ -128,9 +125,9 @@
applyBucketValue(buckets, "troops.count", armyTerm);
applyBucketValue(buckets, "source.extraSkill", job.kind === "skill" ? job.sourceMultiplier ?? 1 : 1);
- const appliedEffects: DamageEquationTrace["appliedEffects"] = detail === "full" ? [] : [];
- const rejectedEffects: DamageEquationTrace["rejectedEffects"] = detail === "full" ? [] : [];
- const consumedEffectIds = new Set<string>();
+ const appliedEffects: DamageEquationTrace["appliedEffects"] = [];
+ const rejectedEffects: DamageEquationTrace["rejectedEffects"] = [];
+ const usedEffects = options.usedEffects ?? new Set<ActiveEffect>();
const candidates: BucketCandidate[] = [];
const handledCandidateEffectIds = traceEnabled ? new Set<string>() : undefined;
for (const candidate of bucketCandidatesForJob(options.effectIndex, job)) {
@@ -158,7 +155,7 @@
}
}
- applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidates(candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.offense[job.attackerSide][job.attackerUnit]);
if (traceEnabled) appendStaticProfileAppliedEffects(appliedEffects, staticProfile.defense[job.defenderSide][job.defenderUnit]);
@@ -184,12 +181,8 @@
}
: undefined;
- const returnedConsumedEffectIds = consumedEffectIds.size === 0 ? EMPTY_CONSUMED_EFFECT_IDS : [...consumedEffectIds];
-
return {
kills,
- consumedEffectIds: returnedConsumedEffectIds,
- appliedEffectIds: traceEnabled ? appliedEffects.map((effect) => effect.effectId) : undefined,
appliedEffects: traceEnabled ? appliedEffects : undefined,
trace
};
@@ -201,7 +194,7 @@
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
let maxGroups: Map<string, MaxBucketCandidateGroup> | undefined;
for (const candidate of candidates) {
@@ -216,12 +209,12 @@
maxGroups.set(key, { selected: candidate, candidates: [candidate] });
}
} else {
- applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidate(candidate, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
}
}
if (!maxGroups) return;
for (const group of maxGroups.values()) {
- applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, consumedEffectIds);
+ applyBucketCandidateGroup(group.selected, group.candidates, buckets, detail, appliedEffects, rejectedEffects, usedEffects);
}
}
@@ -246,6 +239,8 @@
);
if (appliedValuePct !== 0 && detail === "full") {
const appliedEffect: DamageEquationTrace["appliedEffects"][number] = {
+ kind: "modifier",
+ activeEffectId: selected.effect.id,
effectId: selected.effect.source.effectId ?? selected.effect.id,
bucket: selected.bucket,
valuePct: appliedValuePct,
@@ -267,11 +262,11 @@
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
const appliedValuePct = applySelectedBucket(candidate, buckets, detail, appliedEffects);
if (appliedValuePct !== 0) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ usedEffects.add(candidate.effect);
} else if (detail === "full") {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_superseded" });
}
@@ -284,12 +279,13 @@
detail: DamageDetail,
appliedEffects: DamageEquationTrace["appliedEffects"],
rejectedEffects: DamageEquationTrace["rejectedEffects"],
- consumedEffectIds: Set<string>
+ usedEffects: Set<ActiveEffect>
): void {
const appliedValuePct = applySelectedBucket(selected, buckets, detail, appliedEffects);
if (appliedValuePct !== 0) {
+ // The whole max-stacking group is charged: suppressed siblings deplete alongside the winner.
for (const candidate of candidates) {
- if (candidate.effect.duration.type === "attack") consumedEffectIds.add(candidate.effect.id);
+ usedEffects.add(candidate.effect);
if (detail === "full" && candidate !== selected) {
rejectedEffects.push({ effectId: candidate.effect.source.effectId ?? candidate.effect.id, reason: "same_effect_max_suppressed" });
}
@@ -355,6 +351,8 @@
if (!bucket.startsWith("passive.") || !term) continue;
for (const contributor of term.contributors) {
appliedEffects.push({
+ kind: "modifier",
+ activeEffectId: contributor.effectId,
effectId: contributor.effectId,
bucket,
valuePct: contributor.valuePct,
@@ -560,6 +558,3 @@
return Number(((factor - 1) * 100).toFixed(12));
}
-function sourceLabel(effect: ActiveEffect): string {
- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
-}
diff --git a/simulator/src/effects.ts b/simulator/src/effects.ts
--- a/simulator/src/effects.ts
+++ b/simulator/src/effects.ts
@@ -14,6 +14,7 @@
import { normalizeUnitType } from "./normalize";
export type Rng = () => number;
+type EffectDurationConstraint = NonNullable<EffectDuration["constraints"]>[number];
interface CompiledTriggerSelectors {
source: ParsedTriggerSelector;
@@ -127,11 +128,17 @@
return skill.heroName ?? skill.troopType ?? "global";
}
+export function sourceLabel(effect: ActiveEffect): string {
+ return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
+}
+
export function isEffectActive(effect: ActiveEffect, round: number): boolean {
if (round < effect.startRound) return false;
- if (effect.duration.type === "battle") return true;
- if (effect.duration.type === "round") return round < effect.startRound + Math.max(1, effect.duration.value);
- return effect.uses < Math.max(1, effect.duration.value);
+ return durationConstraints(effect.duration).every((constraint) => isDurationConstraintActive(effect, round, constraint));
+}
+
+export function hasAttackDurationConstraint(effect: ActiveEffect): boolean {
+ return durationConstraints(effect.duration).some((constraint) => constraint.type === "attack");
}
export function currentEffectValuePct(effect: ActiveEffect, round: number): number {
@@ -229,13 +236,44 @@
function normalizeDuration(duration: EffectIntentDefinition["duration"]): EffectDuration {
if (!duration) return { type: "battle", value: 0 };
- const rawType = duration.type === "turn" ? "round" : duration.type;
- if (rawType === "round" || rawType === "attack" || rawType === "battle") {
- return { type: rawType, value: Number(duration.value ?? (rawType === "battle" ? 0 : 1)), delay: Number(duration.delay ?? 0) };
- }
+ const namedConstraints = normalizeNamedDurationConstraints(duration);
+ if (namedConstraints.length > 0) return { type: "battle", value: 0, constraints: namedConstraints };
return { type: "battle", value: 0 };
}
+function durationConstraints(duration: EffectDuration): EffectDurationConstraint[] {
+ return duration.constraints ?? [{ type: duration.type, count: duration.value }];
+}
+
+function isDurationConstraintActive(effect: ActiveEffect, round: number, constraint: EffectDurationConstraint): boolean {
+ const delay = Math.max(0, constraint.delay ?? 0);
+ if (constraint.type === "battle") return true;
+ if (constraint.type === "round") {
+ const startRound = effect.createdRound + delay;
+ if (round < startRound) return false;
+ if (constraint.count === undefined) return true;
+ return round < startRound + Math.max(1, constraint.count);
+ }
+ if (constraint.count === undefined) return true;
+ return effect.uses < delay + Math.max(1, constraint.count);
+}
+
+function normalizeNamedDurationConstraints(duration: NonNullable<EffectIntentDefinition["duration"]>): EffectDurationConstraint[] {
+ const constraints: EffectDurationConstraint[] = [];
+ const turns = duration.turns ?? duration.rounds;
+ if (turns) constraints.push(normalizeNamedDurationConstraint("round", turns));
+ if (duration.attacks) constraints.push(normalizeNamedDurationConstraint("attack", duration.attacks));
+ return constraints;
+}
+
+function normalizeNamedDurationConstraint(type: "round" | "attack", value: { count?: number; delay?: number }): EffectDurationConstraint {
+ return {
+ type,
+ ...(value.count === undefined ? {} : { count: Number(value.count) }),
+ ...(value.delay === undefined ? {} : { delay: Number(value.delay) })
+ };
+}
+
function normalizeSameEffectStacking(value: EffectIntentDefinition["same_effect_stacking"]): SameEffectStacking {
return value === "max" ? "max" : "add";
}
diff --git a/simulator/src/recorder.ts b/simulator/src/recorder.ts
--- a/simulator/src/recorder.ts
+++ b/simulator/src/recorder.ts
@@ -1,4 +1,5 @@
import type {
+ AppliedEffect,
AttackIntent,
AttackOutcome,
BattleTrace,
@@ -11,7 +12,7 @@
/**
* 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
+ * does all simulation-affecting work itself (kills, counters, effect usage charging); 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.
*
@@ -22,14 +23,15 @@
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;
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void;
+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): void;
recordRound(round: number, roundStartTroops: DamageJob["roundStartTroops"], intents: AttackIntent[], jobs: DamageJob[]): void;
readonly attacks: AttackOutcome[];
readonly trace: BattleTrace | undefined;
}
const NO_ATTACKS: AttackOutcome[] = [];
+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
export const NULL_RECORDER: BattleRecorder = {
capturesTrace: false,
@@ -62,7 +64,7 @@
this.capturesTrace = trace !== undefined;
}
- recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", consumedEffectIds: string[]): void {
+ recordCancelled(intent: AttackIntent, effectId: string, reason: "dodge" | "no_attack", appliedEffects: AppliedEffect[]): void {
this.attacks.push({
jobId: `${intent.id}:cancelled`,
kind: "normal",
@@ -75,15 +77,13 @@
{ 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,
+ appliedEffects,
cancelledBy: effectId,
cancelReason: reason
});
}
- recordDamageJob(job: DamageJob, result: DamageResult): void {
+ recordDamageJob(job: DamageJob, result: DamageResult, extraAppliedEffects?: AppliedEffect[]): 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;
@@ -103,9 +103,7 @@
{ 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,
+ appliedEffects: mergeAppliedEffects(result.appliedEffects, extraAppliedEffects),
trace: result.trace
});
}
@@ -114,3 +112,9 @@
this.trace?.rounds.push({ round, roundStartTroops, intents, jobs });
}
}
+
+function mergeAppliedEffects(applied: AppliedEffect[] | undefined, extra: AppliedEffect[] | undefined): AppliedEffect[] {
+ if (!applied?.length) return extra?.length ? extra : NO_APPLIED_EFFECTS;
+ if (!extra?.length) return applied;
+ return [...applied, ...extra];
+}
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
@@ -7,7 +7,7 @@
import { createSeededRng, chancePasses } from "./effects";
import { applyHeroGenerationStats, resolveFighter } from "./resolve";
import { prepareBattle, runPrepared, simulateBattle, simulateBearBattle, signedRemainingScore } from "./simulator";
-import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulatorConfig, SkillFile, UnitType } from "./types";
+import type { BattleInput, EffectIntentDefinition, FighterInput, ResolvedSkill, SimulationOptions, SimulatorConfig, SkillFile, UnitType } from "./types";
test("runPrepared reuses the compiled input seed when no override is supplied", () => {
const config = loadSimulatorConfig();
@@ -257,7 +257,7 @@
type: "active.hero.lethality.up",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
@@ -296,7 +296,7 @@
type: "active.hero.defense.up",
value: 100,
units: { applies_to: "target", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -311,6 +311,60 @@
assert.equal(report?.effectActivations, 2);
});
+test("attack-duration effects with a round cap expire at the end of their active turn even when unused", () => {
+ const result = simulateBattle(
+ {
+ maxRounds: 4,
+ attacker: {
+ troops: { lancer_t1: 1000000 },
+ heroes: { DreamLancer: { skill_1: 1, skill_2: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000000, marksman_t1: 1000000 },
+ heroes: {}
+ }
+ },
+ minimalConfig({
+ DreamLancer: {
+ name: "DreamLancer",
+ troop_type: "lancer",
+ skills: {
+ EvenTurnAmbusher: {
+ trigger: { type: "turn", every: 2 },
+ effects: {
+ order: {
+ type: "attack_order",
+ value: ["marksman", "infantry", "lancer"],
+ units: { applies_to: "lancer", applies_vs: "marksman" },
+ duration: { turns: { count: 1 } }
+ }
+ }
+ },
+ NightmareTrace: {
+ trigger: { type: "turn", every: 2, source: "lancer" },
+ effects: {
+ mark: {
+ type: "active.hero.defense.down",
+ value: 100,
+ units: { applies_to: "target", applies_vs: "lancer" },
+ duration: { turns: { count: 1, delay: 1 }, attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ }),
+ { mode: "trace" }
+ );
+
+ const lancerAttacks = result.attacks.filter((attack) => attack.attackerSide === "attacker" && attack.attackerUnit === "lancer");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r2:"))?.defenderUnit, "marksman");
+ assert.equal(lancerAttacks.find((attack) => attack.jobId.startsWith("r3:"))?.defenderUnit, "infantry");
+ const round4LancerAttack = lancerAttacks.find((attack) => attack.jobId.startsWith("r4:"));
+ assert.equal(round4LancerAttack?.defenderUnit, "marksman");
+ assert.equal(round4LancerAttack?.appliedEffects.some((effect) => effect.effectId === "mark"), false);
+});
+
test("simulateBattle reports resolved heroes, troop skills, activations, controls, and extra skill jobs", () => {
const config = loadSimulatorConfig();
const result = simulateBattle(
@@ -416,7 +470,7 @@
type: "active.hero.lethality.up",
value: 10,
units: { applies_to: "self.infantry", applies_vs: "enemy.infantry" },
- duration: { type: "battle", value: 1 },
+ duration: {},
same_effect_stacking: "add"
}
}
@@ -472,7 +526,7 @@
assert.deepEqual(fighter.statBonuses.infantry, { attack: 11, defense: 22, lethality: 33, health: 44 });
});
-test("attack-duration effects are consumed even when a normal attack is cancelled", () => {
+test("cancelled attacks report the winning control as an applied effect", () => {
const result = simulateBattle(
{
maxRounds: 1,
@@ -496,33 +550,89 @@
type: "no_attack",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
debuff: {
type: "active.hero.attack.up",
value: 100,
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
}
}
- })
+ }),
+ { mode: "trace" }
);
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
const cancelledAttack = cancelled!;
- assert.equal(cancelledAttack.consumedEffectIds.length, 2);
- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":cancel:")));
- assert.ok(cancelledAttack.consumedEffectIds.some((id) => id.includes(":debuff:")));
+ assert.equal(cancelledAttack.appliedEffects.length, 1);
+ const controlEvent = cancelledAttack.appliedEffects[0];
+ assert.equal(controlEvent.kind, "control");
+ assert.equal(controlEvent.kind === "control" && controlEvent.reason, "no_attack");
+ assert.ok(controlEvent.activeEffectId.includes(":cancel:"));
assert.deepEqual(cancelledAttack.counterDeltas, [
{ side: "attacker", unit: "infantry", counter: "attacks", by: 1, cause: "normal_attack" },
{ side: "defender", unit: "infantry", counter: "received_attacks", by: 1, cause: "normal_attack" }
]);
});
+test("attack-duration effects charged on a cancelled attack unless useEffectsOnNoAttack is disabled", () => {
+ // Round 1: the attacker's infantry attack is cancelled while an attack-1-duration buff is
+ // active. By default the cancelled attack charges the buff, so round 2 lands without it;
+ // with useEffectsOnNoAttack:false the buff survives to boost round 2.
+ const input = {
+ maxRounds: 2,
+ seed: "cancel-charge",
+ attacker: {
+ troops: { infantry_t1: 1000 },
+ heroes: { SelfStunned: { skill_1: 1 } }
+ },
+ defender: {
+ troops: { infantry_t1: 1000 },
+ heroes: {}
+ }
+ };
+ const config = minimalConfig({
+ SelfStunned: {
+ name: "SelfStunned",
+ skills: {
+ StunAndBuff: {
+ trigger: { type: "battle_start" },
+ effects: {
+ stun: {
+ type: "no_attack",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { turns: { count: 1, delay: 1 } }
+ },
+ buff: {
+ type: "active.hero.attack.up",
+ value: 100,
+ units: { applies_to: "self.infantry", applies_vs: "any" },
+ duration: { attacks: { count: 1 } }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ const roundTwoKills = (options: SimulationOptions): number => {
+ const result = simulateBattle(input, config, options);
+ const attack = result.attacks.find((entry) => entry.kind === "normal" && entry.jobId.startsWith("r2:attacker:infantry"));
+ assert.notEqual(attack, undefined);
+ return attack!.kills;
+ };
+
+ const charged = roundTwoKills({});
+ const uncharged = roundTwoKills({ useEffectsOnNoAttack: false });
+ assert.ok(uncharged > charged, `expected uncharged buff to boost round 2 (${uncharged} > ${charged})`);
+});
+
test("cancelled normal attacks advance attack counters for later frequency checks", () => {
const result = simulateBattle(
{
@@ -546,7 +656,7 @@
cancel: {
type: "no_attack",
units: { applies_to: "trigger", applies_vs: "target" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -637,7 +747,7 @@
same_effect_stacking: "max",
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -651,9 +761,11 @@
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);
+ const extraAttackEvents = roundTwoNormalOutcome!.appliedEffects.filter((event) => event.kind === "extra_attack");
+ assert.equal(extraAttackEvents.length, 1);
+ assert.equal(extraAttackEvents[0].kind === "extra_attack" && extraAttackEvents[0].spawnedJobIds.length, 1);
assert.notEqual(roundTwoSkillOutcome, undefined);
- assert.equal(roundTwoSkillOutcome!.consumedEffectIds.length, 0);
+ assert.equal(roundTwoSkillOutcome!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
});
test("requires_effect is ignored by native simulator effect activation", () => {
@@ -767,14 +879,14 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: ["lancer"] }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
hitMarksman: {
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: ["marksman"] }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -819,7 +931,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -861,7 +973,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -877,7 +989,7 @@
type: "active.hero.defense.up",
value: 50,
units: { applies_to: "trigger.target", applies_vs: "trigger.source" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -919,14 +1031,14 @@
value: 500,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
},
second: {
type: "extra_skill_attack",
value: 500,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1022,7 +1134,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source" } as never],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1061,7 +1173,7 @@
type: "extra_skill_attack",
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1098,7 +1210,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1145,7 +1257,7 @@
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1161,7 +1273,7 @@
type: "no_attack",
value: 100,
units: { applies_to: "enemy.marksman", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1173,7 +1285,7 @@
const cancelled = result.attacks.find((attack) => attack.cancelReason === "no_attack");
assert.notEqual(cancelled, undefined);
- assert.equal(cancelled!.consumedEffectIds.some((id) => id.includes(":hitAgain:")), false);
+ assert.equal(cancelled!.appliedEffects.some((event) => event.kind === "extra_attack"), false);
const skillJobsByRound = result.trace?.rounds.map((round) => round.jobs.filter((job) => job.kind === "skill").length) ?? [];
assert.deepEqual(skillJobsByRound, [0, 1]);
@@ -1205,7 +1317,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1248,7 +1360,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
},
@@ -1260,7 +1372,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1304,7 +1416,7 @@
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1382,7 +1494,7 @@
value: 100,
units: { applies_to: ["infantry", "marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1422,7 +1534,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: ["lancer"] },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1465,7 +1577,7 @@
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "enemy.living" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
},
@@ -1476,7 +1588,7 @@
type: "type.skill.damage.up",
value: 100,
units: { applies_to: ["marksman"], applies_vs: "any" },
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1519,7 +1631,7 @@
type: "active.hero.lethality.down",
value: 50,
units: { applies_to: "trigger.source", applies_vs: "trigger.target" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
@@ -1562,7 +1674,7 @@
type: "active.hero.lethality.down",
value: 50,
units: { applies_to: "trigger.target", applies_vs: "any" },
- duration: { type: "turn", value: 1 }
+ duration: { turns: { count: 1 } }
}
}
}
@@ -1779,7 +1891,7 @@
value: 100,
units: { applies_to: "trigger.source", applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "attack", value: 2 }
+ duration: { attacks: { count: 2 } }
}
}
}
@@ -1794,7 +1906,7 @@
pause: {
type: "no_attack",
units: { applies_to: "enemy.marksman", applies_vs: "any" },
- duration: { type: "attack", value: 1 }
+ duration: { attacks: { count: 1 } }
}
}
}
@@ -1938,14 +2050,14 @@
same_effect_stacking,
units: { applies_to: ["infantry"], applies_vs: "any" },
trigger_damage_jobs: [{ source: "use.source", target: "use.target" }],
- duration: { type: "turn", value: 2 }
+ duration: { turns: { count: 2 } }
}
: {
type,
value: 100,
same_effect_stacking,
units: { applies_to: ["infantry"], applies_vs: "any" },
- duration: { type: "turn", value: 2 }
+ duration: { turns: { count: 2 } }
};
return minimalConfig({
[heroName]: {
diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts
--- a/simulator/src/simulator.ts
+++ b/simulator/src/simulator.ts
@@ -1,5 +1,9 @@
import type {
ActiveEffect,
+ AppliedControlEffect,
+ AppliedEffect,
+ AppliedExtraAttackEffect,
+ AppliedOrderEffect,
AttackIntent,
AttackOutcome,
BearBattleResult,
@@ -27,11 +31,13 @@
chancePasses,
createSeededRng,
currentEffectValuePct,
+ hasAttackDurationConstraint,
isEffectActive,
oppositeSide,
parseTriggerSelector,
sideForTriggerRelation,
skillMatchesTrigger,
+ sourceLabel,
type Rng
} from "./effects";
import { classifyEffectForJob } from "./classifier";
@@ -49,7 +55,9 @@
interface Runtime {
activeEffects: ActiveEffect[];
effectIndex: EffectIndex;
- effectById: Map<string, ActiveEffect>;
+ // Per-job scratch: effects that affected the job being calculated; drained by
+ // chargeUsedEffects (uses += 1 each) after every job in every mode.
+ usedEffects: Set<ActiveEffect>;
staticDamageProfile?: StaticDamageProfile;
damageScratch: DamageScratch;
rng: Rng;
@@ -80,7 +88,8 @@
interface ExtraSkillJobsResult {
jobs: DamageJob[];
- consumedEffectIds: string[];
+ usedEffects: ActiveEffect[];
+ appliedEffects?: AppliedExtraAttackEffect[];
}
interface BattleRun {
@@ -222,7 +231,7 @@
return {
activeEffects,
effectIndex,
- effectById: new Map(activeEffects.map((e) => [e.id, e])),
+ usedEffects: new Set(),
staticDamageProfile: template.staticDamageProfile,
damageScratch: createFastDamageScratch(),
rng,
@@ -299,6 +308,10 @@
const mode = options.mode ?? "standard";
const recorder = createRecorder(mode, runtime.skillReports, () => buildResolved(fighters.attacker, fighters.defender));
const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS;
+ const useEffectsOnCancel = {
+ dodge: options.useEffectsOnDodge ?? true,
+ no_attack: options.useEffectsOnNoAttack ?? true
+ };
let rounds = 0;
let score = 0;
@@ -309,7 +322,9 @@
expireInactive(runtime, round);
triggerRoundStartSkills(round, runtime, roundStartTroops);
- const intents = resolveAttackIntents(round, runtime, roundStartTroops);
+ // Trace-only: battle_order applied events keyed by the intent they ordered.
+ const orderEvents = recorder.capturesTrace ? new Map<string, AppliedOrderEffect>() : undefined;
+ const intents = resolveAttackIntents(round, runtime, roundStartTroops, orderEvents);
const allJobs: DamageJob[] = []; // for recorder
const results: DamageJobResult[] = [];
const cancelled: CancelledAttack[] = [];
@@ -324,9 +339,15 @@
if (controls.no_attack || controls.dodge) {
const control = controls.no_attack ?? controls.dodge!;
runtime.attackControlCounts[control.reason] += 1;
- const consumedEffectIds = attackDurationEffectIdsForJob(job, runtime.effectIndex);
- cancelled.push({ intent, effectId: control.effect.id, reason: control.reason, consumedEffectIds });
- consumeEffects(runtime, consumedEffectIds);
+ if (useEffectsOnCancel[control.reason]) chargeCancelledAttack(job, control.effect, runtime);
+ cancelled.push({
+ intent,
+ effectId: control.effect.id,
+ reason: control.reason,
+ appliedEffects: recorder.capturesTrace
+ ? appendedEvent(orderEvents?.get(intent.id), appliedControlEvent(control))
+ : NO_APPLIED_EFFECTS
+ });
} else {
pendingNormalJobs.push(job);
}
@@ -334,7 +355,7 @@
// 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
+ // chargeUsedEffects 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);
@@ -343,19 +364,21 @@
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
});
- 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);
- }
+ chargeUsedEffects(runtime);
+
+ const extraSkill = extraSkillJobs(job, round, runtime, roundStartTroops, recorder.capturesTrace);
+ for (const usedEffect of extraSkill.usedEffects) usedEffect.uses += 1;
+ results.push({
+ job,
+ result: normalResult,
+ extraAppliedEffects: appendedEvents(orderEvents?.get(job.sourceIntentId ?? ""), extraSkill.appliedEffects)
+ });
for (const extraJob of extraSkill.jobs) {
allJobs.push(extraJob);
@@ -364,13 +387,14 @@
effectIndex: runtime.effectIndex,
staticDamageProfile: runtime.staticDamageProfile,
scratch: recorder.capturesTrace ? undefined : runtime.damageScratch,
- capToDefenderTroops: loopOptions.capJobKills
+ capToDefenderTroops: loopOptions.capJobKills,
+ usedEffects: runtime.usedEffects
});
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);
+ chargeUsedEffects(runtime);
}
}
@@ -378,8 +402,8 @@
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);
+ for (const entry of cancelled) recorder.recordCancelled(entry.intent, entry.effectId, entry.reason, entry.appliedEffects);
+ for (const entry of results) recorder.recordDamageJob(entry.job, entry.result, entry.extraAppliedEffects);
recorder.recordRound(round, roundStartTroops, intents, allJobs);
}
@@ -399,12 +423,37 @@
intent: AttackIntent;
effectId: string;
reason: "dodge" | "no_attack";
- consumedEffectIds: string[];
+ appliedEffects: AppliedEffect[];
}
interface DamageJobResult {
job: DamageJob;
result: DamageResult;
+ extraAppliedEffects?: AppliedEffect[];
+}
+
+const NO_APPLIED_EFFECTS: AppliedEffect[] = [];
+
+function appendedEvent(order: AppliedOrderEffect | undefined, event: AppliedEffect): AppliedEffect[] {
+ return order ? [order, event] : [event];
+}
+
+function appendedEvents(order: AppliedOrderEffect | undefined, events: AppliedEffect[] | undefined): AppliedEffect[] | undefined {
+ if (!order) return events;
+ return events ? [order, ...events] : [order];
+}
+
+function appliedEffectBase(effect: ActiveEffect): { activeEffectId: string; effectId: string; source: string; sourceSide: SideId } {
+ return {
+ activeEffectId: effect.id,
+ effectId: effect.source.effectId ?? effect.id,
+ source: sourceLabel(effect),
+ sourceSide: effect.ownerSide
+ };
+}
+
+function appliedControlEvent(control: Control): AppliedControlEffect {
+ return { kind: "control", ...appliedEffectBase(control.effect), reason: control.reason };
}
function triggerRoundStartSkills(
@@ -529,7 +578,7 @@
return {
activeEffects: [],
effectIndex: createEffectIndex(),
- effectById: new Map(),
+ usedEffects: new Set(),
staticDamageProfile: undefined,
damageScratch: createFastDamageScratch(),
rng,
@@ -562,7 +611,6 @@
function addActiveEffect(runtime: Runtime, effect: ActiveEffect): void {
runtime.activeEffects.push(effect);
- runtime.effectById.set(effect.id, effect);
indexEffect(runtime.effectIndex, effect);
}
@@ -641,7 +689,8 @@
function resolveAttackIntents(
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ orderEvents?: Map<string, AppliedOrderEffect>
): AttackIntent[] {
const intents: AttackIntent[] = [];
for (const side of ["attacker", "defender"] as SideId[]) {
@@ -649,12 +698,18 @@
let orderIndex = 0;
for (const attackerUnit of UNIT_TYPES) {
if ((roundStartTroops[side][attackerUnit] ?? 0) <= 0) continue;
- const defenderUnit = chooseDefenderUnit(attackerUnit, side, defenderSide, roundStartTroops, runtime.effectIndex, round);
+ const ordered = orderFromEffects(attackerUnit, side, runtime.effectIndex, round);
+ const defenderUnit = firstLivingUnit(ordered?.order ?? UNIT_TYPES, defenderSide, roundStartTroops);
if (!defenderUnit) continue;
+ const intentId = `r${round}:${side}:${attackerUnit}:${orderIndex}`;
+ if (ordered) {
+ ordered.effect.uses += 1;
+ orderEvents?.set(intentId, { kind: "battle_order", ...appliedEffectBase(ordered.effect), chosenTarget: defenderUnit });
+ }
const previousAttackCount = runtime.counters.attacks[side][attackerUnit];
const previousReceivedAttackCount = runtime.counters.received[defenderSide][defenderUnit];
intents.push({
- id: `r${round}:${side}:${attackerUnit}:${orderIndex}`,
+ id: intentId,
round,
source: "normal",
attackerSide: side,
@@ -673,6 +728,8 @@
return intents;
}
+// Synthetic round-start trigger targeting reuses attack-order effects to pick a target but
+// does not charge them: no real attack intent is being ordered.
function chooseDefenderUnit(
attackerUnit: UnitType,
attackerSide: SideId,
@@ -681,17 +738,26 @@
effectIndex: EffectIndex,
round: number
): UnitType | undefined {
- const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round) ?? UNIT_TYPES;
- return order.find((unit) => (roundStartTroops[defenderSide][unit] ?? 0) > 0);
+ const order = orderFromEffects(attackerUnit, attackerSide, effectIndex, round)?.order ?? UNIT_TYPES;
+ return firstLivingUnit(order, defenderSide, roundStartTroops);
}
-function orderFromEffects(attackerUnit: UnitType, attackerSide: SideId, index: EffectIndex, round: number): UnitType[] | undefined {
+function firstLivingUnit(order: readonly UnitType[], side: SideId, roundStartTroops: DamageJob["roundStartTroops"]): UnitType | undefined {
+ return order.find((unit) => (roundStartTroops[side][unit] ?? 0) > 0);
+}
+
+function orderFromEffects(
+ attackerUnit: UnitType,
+ attackerSide: SideId,
+ index: EffectIndex,
+ round: number
+): { order: UnitType[]; effect: ActiveEffect } | undefined {
for (const effect of index.battleOrder) {
if (!isEffectActive(effect, round) || effect.intent.type !== "attack_order") continue;
if (effect.appliesTo.side !== attackerSide || !unitMaskHas(effect.appliesTo.units, attackerUnit)) continue;
if (Array.isArray(effect.intent.value)) {
try {
- return effect.intent.value.map((value) => normalizeUnitType(String(value)));
+ return { order: effect.intent.value.map((value) => normalizeUnitType(String(value))), effect };
} catch {
return undefined;
}
@@ -740,10 +806,12 @@
normalAttack: DamageJob,
round: number,
runtime: Runtime,
- roundStartTroops: DamageJob["roundStartTroops"]
+ roundStartTroops: DamageJob["roundStartTroops"],
+ capturesTrace: boolean
): ExtraSkillJobsResult {
const jobs: DamageJob[] = [];
- const consumedEffectIds: string[] = [];
+ const usedEffects: ActiveEffect[] = [];
+ let appliedEffects: AppliedExtraAttackEffect[] | undefined;
const effectGroups = selectStackedExtraAttackEffectGroups(
runtime.effectIndex.extraAttacks.filter((effect) => isEffectActive(effect, round) && extraAttackEffectAppliesToNormalAttack(effect, normalAttack)),
round
@@ -784,10 +852,19 @@
definitionIndex += 1;
}
if (jobs.length > firstJobIndex) {
- consumedEffectIds.push(...effectGroup.effects.map((groupEffect) => groupEffect.id));
+ // The whole stacking group is charged whenever the selected effect spawned jobs,
+ // regardless of duration constraints (extra attacks always deplete per firing).
+ usedEffects.push(...effectGroup.effects);
+ if (capturesTrace) {
+ (appliedEffects ??= []).push({
+ kind: "extra_attack",
+ ...appliedEffectBase(effect),
+ spawnedJobIds: jobs.slice(firstJobIndex).map((spawned) => spawned.id)
+ });
+ }
}
}
- return { jobs, consumedEffectIds };
+ return { jobs, usedEffects, appliedEffects };
}
function selectStackedExtraAttackEffectGroups(effects: ActiveEffect[], round: number): ExtraAttackEffectGroup[] {
@@ -926,24 +1003,28 @@
}
}
-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);
- }
- 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;
+// Drain the per-job used-effects scratch, advancing each effect's uses counter. Runs in
+// every mode: uses drives attack-constraint expiry and step:"attack" value evolution.
+function chargeUsedEffects(runtime: Runtime): void {
+ if (runtime.usedEffects.size === 0) return;
+ for (const effect of runtime.usedEffects) effect.uses += 1;
+ runtime.usedEffects.clear();
}
-function consumeEffects(runtime: Runtime, consumedEffectIds: string[]): void {
- for (const consumed of consumedEffectIds) {
- const effect = runtime.effectById.get(consumed);
- if (effect) effect.uses += 1;
+// A cancelled attack still charges the attacker's attack-constrained effects (the attack
+// happened, it just didn't land) plus the control that cancelled it, whatever its duration.
+function chargeCancelledAttack(job: DamageJob, winningControl: ActiveEffect, runtime: Runtime): void {
+ const used = runtime.usedEffects;
+ for (const candidate of bucketCandidatesForJob(runtime.effectIndex, job)) {
+ if (hasAttackDurationConstraint(candidate.effect)) used.add(candidate.effect);
+ }
+ for (const effect of runtime.effectIndex.controls) {
+ if (!hasAttackDurationConstraint(effect)) continue;
+ const classification = classifyEffectForJob(effect, job);
+ if (classification?.kind === "control") used.add(effect);
}
+ used.add(winningControl);
+ chargeUsedEffects(runtime);
}
function winnerFor(fighters: Record<SideId, ResolvedFighter>): SideId | undefined {
diff --git a/simulator/src/staticDamageProfile.ts b/simulator/src/staticDamageProfile.ts
--- a/simulator/src/staticDamageProfile.ts
+++ b/simulator/src/staticDamageProfile.ts
@@ -1,7 +1,7 @@
import type { ActiveEffect, DamageBucketTrace, EffectIntentDefinition, ResolvedFighter, SideId, SkillFile, StatBlock, UnitType } from "./types";
import { UNIT_TYPES, unitMaskHas } from "./types";
import { BUCKET_DEFINITIONS, bucketDefinition, STATIC_BUCKETS, type BucketPlacement, type BucketRole, type BucketValueType } from "./damageBuckets";
-import { currentEffectValuePct } from "./effects";
+import { currentEffectValuePct, sourceLabel } from "./effects";
export interface StaticDamageProfileTerm {
raw?: number;
@@ -90,13 +90,9 @@
const duration = effect.duration;
if (duration === undefined) return;
- const durationType = duration.type ?? "battle";
- if (durationType !== "battle") {
+ if (duration.turns !== undefined || duration.rounds !== undefined || duration.attacks !== undefined) {
throw new Error(`passive effect ${effect.type} must use battle duration at ${path}`);
}
- if (duration.delay !== undefined && duration.delay !== 0) {
- throw new Error(`passive effect ${effect.type} must use battle duration with no delay at ${path}`);
- }
}
export function isStaticProfileBucket(bucket: string): bucket is StaticDamageBucket {
@@ -266,9 +262,6 @@
return { attack: 0, defense: 0, lethality: 0, health: 0 };
}
-function sourceLabel(effect: ActiveEffect): string {
- return [effect.source.heroName ?? effect.source.troopType ?? effect.source.kind, effect.source.skillId, effect.source.effectId].filter(Boolean).join("/");
-}
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);
diff --git a/simulator/src/types.ts b/simulator/src/types.ts
--- a/simulator/src/types.ts
+++ b/simulator/src/types.ts
@@ -78,6 +78,7 @@
type: "battle" | "round" | "attack";
value: number;
delay?: number;
+ constraints?: Array<{ type: "battle" | "round" | "attack"; count?: number; delay?: number }>;
}
export interface EffectIntentDefinition {
@@ -87,7 +88,11 @@
value_evolution?: { type?: string; step?: string; value?: number };
units?: Record<string, unknown>;
trigger_damage_jobs?: TriggerDamageJobDefinition[];
- duration?: { type?: string; value?: number; delay?: number };
+ duration?: {
+ turns?: { count?: number; delay?: number };
+ rounds?: { count?: number; delay?: number };
+ attacks?: { count?: number; delay?: number };
+ };
same_effect_stacking?: SameEffectStacking;
reason?: string;
}
@@ -172,6 +177,10 @@
export interface SimulationOptions {
mode?: SimulationMode;
+ // Whether a dodged / no_attack'd attack still charges (uses += 1) the attacker's
+ // attack-constrained effects, as the game does. Default true.
+ useEffectsOnDodge?: boolean;
+ useEffectsOnNoAttack?: boolean;
}
export interface ResolvedTroopLine {
@@ -245,6 +254,10 @@
createdRound: number;
startRound: number;
duration: EffectDuration;
+ // Times this instance affected battle mechanics (damage bucket applied, control fired,
+ // attack ordered, extra attack spawned). Attack duration constraints and step:"attack"
+ // value evolution read it; cancelled attacks still charge attack-constrained effects
+ // unless useEffectsOnDodge/useEffectsOnNoAttack disable that.
uses: number;
stackingKey?: string;
sameEffectStacking: SameEffectStacking;
@@ -310,22 +323,49 @@
armyTerm: number;
atomicBuckets: Record<string, DamageBucketTrace>;
aggregationGroups: Record<string, DamageAggregationGroupTrace>;
- appliedEffects: AppliedEffectTrace[];
+ appliedEffects: AppliedModifierEffect[];
rejectedEffects: Array<{ effectId: string; reason: string }>;
rawDamage: number;
finalKills: number;
}
-export interface AppliedEffectTrace {
+// One "this effect affected battle mechanics" event, discriminated on ActiveEffect.kind.
+// Events are built only in trace mode; the uses counter is charged in every mode.
+interface AppliedEffectBase {
+ // ActiveEffect.id — the runtime instance. Static-profile/input-stat contributors have no
+ // ActiveEffect and use the config-level effectId here too.
+ activeEffectId: string;
+ // Config-level id (source.effectId ?? ActiveEffect.id).
effectId: string;
- bucket: string;
- valuePct: number;
source: string;
sourceSide?: SideId;
+}
+
+export interface AppliedModifierEffect extends AppliedEffectBase {
+ kind: "modifier";
+ bucket: string;
+ valuePct: number;
stackingKey?: string;
sameEffectStacking?: SameEffectStacking;
}
+export interface AppliedControlEffect extends AppliedEffectBase {
+ kind: "control";
+ reason: "dodge" | "no_attack";
+}
+
+export interface AppliedOrderEffect extends AppliedEffectBase {
+ kind: "battle_order";
+ chosenTarget: UnitType;
+}
+
+export interface AppliedExtraAttackEffect extends AppliedEffectBase {
+ kind: "extra_attack";
+ spawnedJobIds: string[];
+}
+
+export type AppliedEffect = AppliedModifierEffect | AppliedControlEffect | AppliedOrderEffect | AppliedExtraAttackEffect;
+
export interface AttackOutcome {
jobId: string;
kind: DamageKind;
@@ -337,9 +377,7 @@
defenderUnit: UnitType;
kills: number;
counterDeltas: CounterDelta[];
- appliedEffectIds: string[];
- appliedEffects: AppliedEffectTrace[];
- consumedEffectIds: string[];
+ appliedEffects: AppliedEffect[];
cancelledBy?: string;
cancelReason?: "dodge" | "no_attack";
trace?: DamageEquationTrace;

Accuracy Results

201 / 201
FileCase#AdjS nG nS μG μBias%tqPW
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_90115302.05302.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_10111-1618.0-1618.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_11211-376.0-376.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_12311-6.0-6.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_134111462.01462.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_14511525.0525.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_156+0.013%11-254.0-254.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_167+0.008%111425.01425.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_178-0.003%11986.0986.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_189+0.017%11-177.0-177.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_191011-489.0-489.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2011-0.006%11712.0712.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2112+0.013%11-193.0-193.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2213111309.01309.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_231411-277.0-277.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2415-0.008%11585.0585.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_251611-45.0-45.00.00%P
testcases/1-testcases_no-heroes_t6_single-type_nc.jsondaut_viper_2617-0.002%11798.0798.00.00%P
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_10+0.05%11735.0741.0-0.32%F
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_21+0.05%11-62.0-61.0-0.06%P
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_32+0.05%11771.0778.0-0.38%F
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_43+0.05%11345.0353.0-0.45%F
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_54+0.043%11264.0264.00.00%P
testcases/2-testcases_no-heroes_t6_mixed_nc.jsondaut_viper_65+0.05%11-90.0-89.0-0.07%P
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_10+0.05%11345.0342.00.19%F
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_211001-3729.0-3729.00.00%P
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_321001-2912.3-2823.0-0.42%-3.470.015P
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_431001-516.1-519.00.02%0.400.89P
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_5410019362.09282.00.64%1.860.50P
testcases/3-testcases_mixed-heroes-not-verified.jsondaut_viper_751001-2273.1-2295.00.16%2.400.21P
testcases/4-testcases_no-heroes_infantry_fc5.jsonNitro_Beast_30_1010013-45545.1-45251.0-0.22%-0.580.87P
testcases/emulator_verified/ahmose_solo_nc.jsonahmose_solo0-0.05%112636.02633.00.06%F
testcases/emulator_verified/alonso_attacker_600_all.jsonalonso_attacker_600_all01006-256.4-253.0-0.14%-0.750.87P
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer01004-463.5-464.50.04%1.820.50P
testcases/emulator_verified/alonso_attacker_900_inf_lancer.jsonalonso_attacker_900_inf_lancer11002-459.6-460.00.02%0.480.88P
testcases/emulator_verified/alonso_minxxx_defender.jsonalonso_minxxx_defender010081453.41450.40.14%0.460.88P
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo01002-393.4-393.0-0.02%-0.190.97P
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo11001-353.4-349.0-0.26%-1.650.53P
testcases/emulator_verified/alonso_norah_combo.jsonalonso_norah_combo21002-393.1-390.0-0.19%-1.450.58P
testcases/emulator_verified/alonso_solo.jsonalonso_solo0100101243.41104.82.97%1.200.70P
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v201006-493.2-513.71.58%0.870.84P
testcases/emulator_verified/alonso_solo_v2.jsonalonso_solo_v2110019-10.010.6-1.59%-1.060.78P
testcases/emulator_verified/alonso_vs_alonso.jsonalonso_vs_alonso010081551.01554.6-0.17%-0.660.87P
testcases/emulator_verified/alonso_vs_alonso_inf_lancer.jsonalonso_vs_alonso_inf_lancer01008-751.5-749.0-0.04%-0.470.88P
testcases/emulator_verified/alonso_wip_attacker.jsonalonso_wip_attacker010081768.21768.9-0.03%-0.400.89P
testcases/emulator_verified/ambusher_no_marksmen.jsonambusher_no_marksmen01001-99987.6-105686.01.87%1.860.50P
testcases/emulator_verified/attdef_jasser_patrick_minxxx_attacks_nc.jsonattdef_jasser_patrick_minxxx_attacks0+0.05%13-807.0-807.00.00%P
testcases/emulator_verified/attdef_jasser_patrick_nc.jsonattdef_jasser_patrick013-1138.0-1138.00.00%P
testcases/emulator_verified/attdef_jasser_patrick_swap_nc.jsonattdef_jasser_patrick_swap0131088.01088.00.00%P
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks0-0.05%11807.0807.00.00%P
testcases/emulator_verified/attdef_jasser_patrick_wip_attacks_nc.jsonattdef_jasser_patrick_wip_attacks1-0.05%12807.0807.00.00%P
testcases/emulator_verified/attdef_test_minxxx_attacks_nc.jsonattdef_test_minxxx_attacks_nc0-0.05%111133.01131.00.08%F
testcases/emulator_verified/attdef_test_wip_attacks_nc.jsonattdef_test_wip_attacks_nc012-1138.0-1138.00.00%P
testcases/emulator_verified/bahiti_norah_combo_nc.jsonbahiti_norah_combo_nc01001193.4269.0-3.15%-0.550.87P
testcases/emulator_verified/bahiti_solo.jsonbahiti_solo010022221.12206.00.32%0.390.89P
testcases/emulator_verified/bahiti_solo_nc.jsonbahiti_solo_nc010013677.03675.00.04%0.380.89P
testcases/emulator_verified/determinism_test_normal_nc.jsondeterminism_test_normal014852.0852.00.00%P
testcases/emulator_verified/determinism_test_swapped_nc.jsondeterminism_test_swapped011-852.0-852.00.00%P
testcases/emulator_verified/flint_reina_zinman_combo.jsonflint_reina_zinman_combo01001-445.1-439.0-0.15%-0.990.80P
testcases/emulator_verified/flint_solo_nc.jsonflint_solo0+0.05%112861.02862.0-0.02%P
testcases/emulator_verified/flint_zinman_combo_nc.jsonflint_zinman_combo_nc0111005.01005.00.00%P
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo010042416.22508.3-1.97%-1.610.53P
testcases/emulator_verified/greg_mia_combo.jsongreg_mia_combo1-0.05%10053482.43432.81.06%5.570.0F
testcases/emulator_verified/greg_mia_defender_current.jsongreg_mia_defender_current0100183347.63351.6-0.09%-0.700.87P
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current0133752.03752.00.00%P
testcases/emulator_verified/greg_mia_nohero_control_current.jsongreg_mia_nohero_control_current1+0.05%133652.03652.00.00%P
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current010063685.93685.80.00%0.030.99P
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current110023585.43585.00.01%0.120.97P
testcases/emulator_verified/greg_only_defender_current.jsongreg_only_defender_current2100203593.83594.6-0.02%-0.630.87P
testcases/emulator_verified/greg_solo.jsongreg_solo010012704.62697.00.16%0.590.87P
testcases/emulator_verified/gwen_norah_combo_nc.jsongwen_norah_combo_nc010012635.02668.0-0.71%-0.540.87P
testcases/emulator_verified/gwen_solo_nc.jsongwen_solo0+0.05%152969.03006.0-0.79%F
testcases/emulator_verified/hector_patrick_combo.jsonhector_patrick_combo01001-590.7-590.0-0.05%-0.970.80P
testcases/emulator_verified/hector_renee_wayne.jsonhector_renee_wayne010011587.91597.0-0.45%-2.940.072P
testcases/emulator_verified/hector_solo.jsonhector_solo01005-388.8-394.40.12%0.510.87P
testcases/emulator_verified/jasser_solo_nc.jsonjasser_solo0+0.05%112740.02740.00.00%P
testcases/emulator_verified/jessie_solo_nc.jsonjessie_solo0-0.05%1379.079.00.00%P
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo0+0.05%112822.02822.00.00%P
testcases/emulator_verified/ling_xue_solo_nc.jsonling_xue_solo1+0.05%113214.03215.0-0.02%P
testcases/emulator_verified/logan_bahiti_combo_nc.jsonlogan_bahiti_combo_nc01001-507.1-517.00.55%0.800.87P
testcases/emulator_verified/logan_gwen_combo_nc.jsonlogan_gwen_combo_nc0+0.05%11-409.0-403.0-0.25%F
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo01001-551.1-553.00.08%1.130.73P
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo11002551.1550.50.03%0.510.87P
testcases/emulator_verified/logan_lynn_combo.jsonlogan_lynn_combo21002551.0551.00.00%0.010.99P
testcases/emulator_verified/logan_reina_bahiti_combo_nc.jsonlogan_reina_bahiti_combo_nc01001-495.2-482.0-0.60%-0.890.84P
testcases/emulator_verified/logan_solo_minxxx_nc.jsonlogan_solo_minxxx011-1995.0-1995.00.00%P
testcases/emulator_verified/logan_solo_v2_nc.jsonlogan_solo_v2011-1134.0-1134.00.00%P
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo0+0.05%112797.02797.00.00%P
testcases/emulator_verified/lumak_bokan_solo_nc.jsonlumak_bokan_solo1+0.05%113127.03127.00.00%P
testcases/emulator_verified/lynn_solo.jsonlynn_solo01005-185.1-181.2-0.08%-0.440.88P
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current0100103540.83543.9-0.07%-0.630.87P
testcases/emulator_verified/mia_only_defender_current.jsonmia_only_defender_current1100203439.73449.2-0.21%-2.460.21P
testcases/emulator_verified/mia_solo.jsonmia_solo01001-269.3-260.0-0.27%-0.970.80P
testcases/emulator_verified/mia_solo.jsonmia_solo110011297.91293.00.22%0.590.87P
testcases/emulator_verified/mia_solo.jsonmia_solo21001-185.0-180.0-0.32%-1.010.80P
testcases/emulator_verified/mia_solo.jsonmia_solo31001879.6879.00.04%0.140.97P
testcases/emulator_verified/mia_solo.jsonmia_solo41001969.9967.00.21%0.650.87P
testcases/emulator_verified/molly_lynn_combo.jsonmolly_lynn_combo01005-402.8-402.80.00%0.040.99P
testcases/emulator_verified/molly_solo.jsonmolly_solo01005244.0244.8-0.07%-1.670.53P
testcases/emulator_verified/natalia_solo.jsonnatalia_solo0+0.05%10012614.52664.0-1.06%-2.390.21F
testcases/emulator_verified/norah_greg_combo.jsonnorah_greg_combo01005-366.7-366.0-0.04%-0.700.87P
testcases/emulator_verified/norah_hector_zinman_combo.jsonnorah_hector_zinman_combo01005-269.3-270.40.07%1.500.58P
testcases/emulator_verified/norah_s2_inf_only_A.jsonnorah_s2_inf_only_A0+0.05%100492.798.5-0.35%-1.190.70P
testcases/emulator_verified/norah_s2_inf_only_B_nc.jsonnorah_s2_inf_only_B0+0.05%13507.0507.00.00%P
testcases/emulator_verified/norah_s2_splash_A.jsonnorah_s2_splash_A010011785.21780.00.23%0.350.90P
testcases/emulator_verified/norah_s2_splash_B_nc.jsonnorah_s2_splash_B0131900.01900.00.00%P
testcases/emulator_verified/norah_s2_splash_C_nc.jsonnorah_s2_splash_C0131153.01153.00.00%P
testcases/emulator_verified/norah_s2_splash_D.jsonnorah_s2_splash_D01003829.5827.30.12%0.720.87P
testcases/emulator_verified/norah_solo.jsonnorah_solo010072740.12753.1-0.28%-0.710.87P
testcases/emulator_verified/norah_zinman_combo_nc.jsonnorah_zinman_combo_nc01001676.2670.00.26%0.100.97P
testcases/emulator_verified/patrick_solo_nc.jsonpatrick_solo0+0.001%112581.02582.0-0.02%P
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo01001-558.5-556.0-0.14%-1.310.68P
testcases/emulator_verified/philly_bahiti_combo.jsonphilly_bahiti_combo11004-454.3-458.30.22%1.600.53P
testcases/emulator_verified/philly_solo.jsonphilly_solo010022262.32253.00.20%0.160.97P
testcases/emulator_verified/philly_solo.jsonphilly_solo110033620.83619.70.02%0.150.97P
testcases/emulator_verified/reina_attacker_wip.jsonreina_attacker_wip01001-1991.7-1992.00.01%0.520.87P
testcases/emulator_verified/reina_bahiti_combo_nc.jsonreina_bahiti_combo_nc01001-483.1-502.01.05%1.160.71P
testcases/emulator_verified/reina_logan_combo_simulator.jsonreina_logan_combo_simulator01001-549.5-553.00.14%1.280.68P
testcases/emulator_verified/reina_solo_wip.jsonreina_solo_wip010011991.61992.0-0.02%-0.590.87P
testcases/emulator_verified/renee_solo_nc.jsonrenee_solo_nc015-554.0-554.00.00%P
testcases/emulator_verified/seo_yoon_solo_nc.jsonseo_yoon_solo0-0.05%13-619.0-620.00.10%P
testcases/emulator_verified/sergey_norah_zinman_combo_nc.jsonsergey_norah_zinman_combo_nc010011324.51372.0-1.58%-0.710.87P
testcases/emulator_verified/sergey_reina_combo_nc.jsonsergey_reina_combo_nc01001-303.3-295.0-0.35%-0.290.93P
testcases/emulator_verified/sergey_solo_nc.jsonsergey_solo0-0.05%121348.01348.00.00%P
testcases/emulator_verified/simple_001_nc.jsonsimple_00101001-186.0-186.00.00%P
testcases/emulator_verified/troop_check_nc.jsontroop_check011-279.0-279.00.00%P
testcases/emulator_verified/type_triangle_A_nc.jsontype_triangle_A_nc0+0.05%11-895.0-895.00.00%P
testcases/emulator_verified/type_triangle_B_nc.jsontype_triangle_B_nc011-929.0-929.00.00%P
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo01004232.9237.0-0.23%-0.110.97P
testcases/emulator_verified/wayne_mixed_solo.jsonwayne_mixed_solo11007310.4346.6-2.01%-1.770.51P
testcases/emulator_verified/wayne_reina_combo.jsonwayne_reina_combo010081550.41559.4-0.37%-1.710.53P
testcases/emulator_verified/wayne_reina_wuming_combo.jsonwayne_reina_wuming_combo010081698.21694.00.18%1.990.45P
testcases/emulator_verified/wayne_s1_solo.jsonwayne_s1_solo01005254.0251.80.36%1.300.68P
testcases/emulator_verified/wayne_s2_solo.jsonwayne_s2_solo01002363.7361.50.43%1.250.68P
testcases/emulator_verified/wos425_wip_nohero_control_nc.jsonwos425_wip_nohero_control_nc011774.0774.00.00%P
testcases/emulator_verified/wos425_wip_patrick_health_nc.jsonwos425_wip_patrick_health_nc011762.0762.00.00%P
testcases/emulator_verified/wos425_wip_philly_defense.jsonwos425_wip_philly_defense01005768.4768.6-0.02%-0.590.87P
testcases/emulator_verified/wos425_wip_sergey_damage_taken_nc.jsonwos425_wip_sergey_damage_taken_nc011749.0749.00.00%P
testcases/emulator_verified/wos425_wip_sergey_patrick_combo_nc.jsonwos425_wip_sergey_patrick_combo_nc011726.0726.00.00%P
testcases/emulator_verified/wos425_wip_sergey_philly_combo.jsonwos425_wip_sergey_philly_combo01005737.8738.2-0.04%-0.880.84P
testcases/emulator_verified/wos427_wip_sergey_philly_840.jsonwos427_wip_sergey_philly_840010048.522.5-0.85%-0.920.84P
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc0+0.05%115764.05848.0-0.82%F
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc1115848.05848.00.00%P
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_control_nc.jsonwos444_bradley_s3_all_troops_damage_control_nc2117010.07010.00.00%P
testcases/emulator_verified/wos444_bradley_s3_all_troops_damage_hero_nc.jsonwos444_bradley_s3_all_troops_damage_hero_nc0118117.08117.00.00%P
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc0122578.02578.00.00%P
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_control_nc.jsonwos444_edith_s11_mark_damage_taken_control_nc1112106.02106.00.00%P
testcases/emulator_verified/wos444_edith_s11_mark_damage_taken_hero_nc.jsonwos444_edith_s11_mark_damage_taken_hero_nc0111863.01863.00.00%P
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_control_nc.jsonwos444_edith_s12_lancer_damage_dealt_control_nc0112332.02332.00.00%P
testcases/emulator_verified/wos444_edith_s12_lancer_damage_dealt_hero_nc.jsonwos444_edith_s12_lancer_damage_dealt_hero_nc0111909.01909.00.00%P
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc0117656.07656.00.00%P
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc1+0.05%117612.07656.0-0.41%F
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_control_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_control_nc2127656.07656.00.00%P
testcases/emulator_verified/wos444_gordon_s22_all_enemy_damage_dealt_hero_nc.jsonwos444_gordon_s22_all_enemy_damage_dealt_hero_nc010055076.85065.40.11%0.130.97P
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_bucket.jsonwos451_bradley_s21_lancer_damage_up_bucket0112736.02736.00.00%P
testcases/emulator_verified/wos451_bradley_s21_lancer_damage_up_control.jsonwos451_bradley_s21_lancer_damage_up_control0-0.025%111504.01504.00.00%P
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_bucket.jsonwos451_bradley_s22_infantry_damage_up_bucket0-0.05%112747.02746.00.03%P
testcases/emulator_verified/wos451_bradley_s22_infantry_damage_up_control.jsonwos451_bradley_s22_infantry_damage_up_control0111623.01623.00.00%P
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_bucket.jsonwos451_bradley_s3_all_damage_up_bucket0116151.06151.00.00%P
testcases/emulator_verified/wos451_bradley_s3_all_damage_up_control.jsonwos451_bradley_s3_all_damage_up_control0114903.04903.00.00%P
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_bucket.jsonwos451_gordon_s11_lancer_damage_up_bucket0-0.05%11-1517.0-1517.00.00%P
testcases/emulator_verified/wos451_gordon_s11_lancer_damage_up_control.jsonwos451_gordon_s11_lancer_damage_up_control0+0.05%11-2739.0-2739.00.00%P
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_bucket.jsonwos451_gordon_s21_lancer_damage_up_bucket0-0.025%11-1017.0-1017.00.00%P
testcases/emulator_verified/wos451_gordon_s21_lancer_damage_up_control.jsonwos451_gordon_s21_lancer_damage_up_control011-1917.0-1917.00.00%P
testcases/emulator_verified/wu_ming_solo_nc.jsonwu_ming_solo0111392.01392.00.00%P
testcases/emulator_verified/zinman_solo_nc.jsonzinman_solo0+0.05%113177.03177.00.00%P
testcases/heroes_unittests/Flint_tc.jsondaut_viper_10+0.05%15-151.0-149.8-0.05%F
testcases/heroes_unittests/Flint_tc.jsondaut_viper_21-0.05%165752.05732.70.27%F
testcases/heroes_unittests/Hector_tc.jsondaut_viper_101001-441.6-441.00.00%-0.130.97P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_10+0.05%11-62.0-61.0-0.06%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_21+0.05%11-90.0-89.0-0.07%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_32-0.025%11481.0481.00.00%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_43-0.017%11217.0217.00.00%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_5411578.0578.00.00%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_65+0.05%11-97.0-96.0-0.06%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_76+0.05%11-75.0-74.0-0.05%P
testcases/heroes_unittests/Jessie_tc_nc.jsondaut_viper_8711-988.0-988.00.00%P
testcases/heroes_unittests/Mia_tc.jsondaut_viper_101001-173.5-181.00.17%1.570.53P
testcases/heroes_unittests/Mia_tc.jsondaut_viper_21+0.05%10071591.81625.1-0.77%-2.250.27F
testcases/heroes_unittests/Mia_tc.jsondaut_viper_3210061122.01124.2-0.10%-1.480.58P
testcases/heroes_unittests/Mia_tc.jsondaut_viper_431001668.1671.0-0.13%-0.310.93P
testcases/heroes_unittests/Mia_tc.jsondaut_viper_541004-124.3-121.8-0.11%-1.250.68P
testcases/heroes_unittests/Molly_tc.jsondaut_viper_10+0.05%11-62.0-61.0-0.06%P
testcases/heroes_unittests/Molly_tc.jsondaut_viper_21+0.05%11-90.0-89.0-0.07%P
testcases/heroes_unittests/Molly_tc.jsondaut_viper_32-0.025%11481.0481.00.00%P
testcases/heroes_unittests/Molly_tc.jsondaut_viper_431001-63.5-68.00.33%3.730.0084P
testcases/heroes_unittests/Molly_tc.jsondaut_viper_541001-190.3-190.0-0.02%-0.210.97P
testcases/heroes_unittests/Reina_tc.jsondaut_viper_10+0.05%11-66.0-62.0-0.18%F
testcases/heroes_unittests/Reina_tc.jsondaut_viper_2111-40.0-40.00.00%P
testcases/heroes_unittests/Reina_tc.jsondaut_viper_4210061413.41428.3-0.46%-0.280.93P
testcases/heroes_unittests/Reina_tc.jsondaut_viper_531001-201.9-202.00.01%0.060.99P
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_10+0.05%11-62.0-61.0-0.06%P
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_61+0.05%11-90.0-89.0-0.07%P
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_32-0.025%11481.0481.00.00%P
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_43+0.05%114339.04341.0-0.04%F
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_54+0.05%113802.03805.0-0.06%F
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_65-0.006%11917.0915.00.04%F
testcases/heroes_unittests/Seo-yoon_tc_nc.jsondaut_viper_76-0.004%112130.02128.00.04%F
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_10-0.05%11-122.0-122.00.00%P
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_21-0.025%114018.04018.00.00%P
testcases/heroes_unittests/Zinman_tc.jsondaut_viper_3211-130.0-130.00.00%P