diff --git a/docs/drafts/2026-08-04-periodic-effect-attribution-goal.md b/docs/drafts/2026-08-04-periodic-effect-attribution-goal.md new file mode 100644 index 00000000..219247d6 --- /dev/null +++ b/docs/drafts/2026-08-04-periodic-effect-attribution-goal.md @@ -0,0 +1,648 @@ +# Periodic Effect Attribution Goal Ledger + +Outcome: Combat Impact shows per-item/skill Burn and Poison realized impact and Regen realized +healing on the existing status row. The model is deterministic and versioned. Values are labelled +by proof class: Exact and Constrained are mathematically unique under a complete observable ledger; +Proportional remains distinguishable in the internal model; Unknown is not presented as attributed +fact. The compact tooltip intentionally omits confidence punctuation. Final in-game presentation +requires user acceptance. + +Appetite: Cross the real replay boundary and complete two substantial model rounds. Continue only +while a round adds goal-level evidence or improves Exact+Constrained coverage without weakening the +invariants below. + +## Model contract + +### Observations + +- Normalize both replay messages, without Unity or Assembly-CSharp: + - spawn `NetMessageGameSim` supplies card instance/template/type/owner through + `GameSimEventCardSpawned`; + - `NetMessageCombatSim` supplies ordered frames, effect executions, player attribute updates, + health adjustments, card updates, and per-card aggregate stats. +- The normalizer records every source or state transition it cannot explain. Missing entity/source + resolution is evidence against Exact, never evidence that no other source existed. +- Attribution is scoped by combatant, status, and a continuous state epoch. A reset, cleanse, + removal, transform, unexplained delta, or unresolved source closes or contaminates an epoch; it + must not silently carry debt across that boundary. + +### Coupled accounting + +The model keeps three coupled ledgers. Their raw units are not interchangeable, and passing only +one is insufficient for Exact/Constrained. + +1. **Status-stock ledger**, per combatant × status × frame/epoch: + + `opening status + source applies + explained external gains - native decay - cleanse/removal = closing status` + + Every term must be supported by an observed transition or a versioned rule that P1 demonstrated + against the current corpus. A net frame delta is not automatically an apply: multiple gains, + ticks, and removals can occur in the same frame. + +2. **Health-impact ledger**, per combatant × damage type × frame: + + `sum(source-attributed Health impact) = sum(combat-effective matching Health adjustments)` + + Burn and Poison debit actual Health; Regen credits actual Health. Typed adjustments are the + authoritative post-modifier input, then the terminal policy removes movement after the combatant + has already died and caps a lethal Health loss at the remaining positive Health. Poison must + never consume or be mitigated by Shield. Overheal, overkill, prevented damage, and other + counterfactual amounts that did not change the live combat state are not booked as realized + Health impact. + +3. **Shield ledger**, per combatant × frame: + + `opening Shield + observed Shield gains - Burn-attributed Shield consumption - other consumption = closing Shield` + + Burn can consume Shield, but Shield points consumed and Burn damage prevented are not assumed to + be numerically equal. Typed Burn Shield adjustments are the authoritative realized consumption. + Versioned bridge formulas remain counterexample-tested diagnostics, not admission gates or a + substitute for observed pool movement. + +The acceptance harness also reconciles the whole pool movement for each frame: + +`opening pool + all observed gains/losses = closing pool` + +This catches duplicated/missing `HealthAdjustment` consumption even when the per-status allocation +still sums to the desired headline number. + +### Realized-impact definition + +- The model version owns an explicit pool policy; it cannot inherit the existing Direct Damage + matcher implicitly. +- P1 must first report actual corpus combinations of `EDamageType` and + `EPlayerHealthChangeType` for Burn, Poison, and Regen. +- Candidate V1 policy, subject to P1 evidence: + - Burn stores separate `HealthDamage`, `ShieldConsumed`, and, only if derivable under the accepted + rule, `DamagePrevented`. These values are never added into one scalar merely because they are + all integers. + - Poison stores `HealthDamage` only. Any Poison-attributed Shield consumption is an invariant + violation, not another supported pool. + - Regen stores positive `HealthRestored` only; overheal that never became a health adjustment is + not realized healing. +- `EPlayerHealthChangeType.Joy` still exists in the shared enum but Joy is not a current gameplay + pool. Enum presence is legacy schema compatibility, not evidence that the model should book it. +- Legacy `BazaarBattleService` behavior is hypothesis evidence only; live server simulation + mechanics must be inferred from current replay observations, not asserted from that code. +- A matching `CombatSimEventCombatantDied` makes the first non-positive ordered Health cursor + terminal for that combatant. Pre-terminal effects still count; post-terminal Burn/Poison Health, + Regen, and later periodic groups do not. A contiguous Burn Health/Shield pair is one atomic tick: + if the Burn group began while alive, its Shield consumption remains realized even when its Health + component is lethal. + +### Current rule knowledge and uncertainty + +- The current game has no Joy gameplay attribute. The model books Health and Shield only. +- Shield interacts with Burn but not Poison. The amount of Shield consumed is not assumed to equal + the amount of Burn damage prevented. +- Burn's decay and Shield interaction have changed across recent patches. The old “decrement one per + tick” rule is false for the current version; neither the legacy battle service nor the stale + community keyword page is acceptable evidence for the replacement formula. +- Poison and Regen timing, decay/offset behavior, cleanse behavior, and same-frame ordering are also + not model axioms merely because older documentation describes them. + +P1 must infer the installed version's mechanics from ordered replay state updates and typed +Health/Shield adjustments. A rule becomes versioned model input only after it reconciles every +relevant observed frame and a counterexample scan finds none. Until then it remains Unknown. + +### Proof classes + +- **Exact:** all status-state changes in the epoch reconcile, every applying source is resolved, and + the source allocation has exactly one feasible solution. +- **Constrained:** multiple candidate sources existed, but the complete observable ledger plus + per-source apply totals leaves exactly one feasible allocation. This is the same uniqueness bar as + Exact; the label records that the result was solved rather than directly observed. +- **Proportional:** more than one feasible allocation remains. Split the realized total with a + deterministic documented weight and preserve sum and non-negativity. Keep the proof class in the + model and tests, but do not add approximation punctuation to the compact display. The result is + not claimed to be marginal contribution or ground truth. +- **Unknown:** the ledger cannot preserve its invariants or lacks a stable candidate set. Do not + fabricate a per-source realized value. + +`CardStats.BurnAdded`, `PoisonAdded`, and `RegenAdded` validate only the apply-side ledger. They do +not validate realized attribution. Realized totals and confidence must use new model fields; they +must not reuse `CombatImpactGroup.ObservedValue`. + +## Boundaries + +- Exact realized adjustment totals by target and affected pool: **verified** — + `CombatSimPlayerHealthAdjustment` carries damage type, changed pool, amount, crit, and reduction. +- Effect executions expose candidate source, trigger source, target, and action type: **verified**. +- Native tick events preserve applying source: **failed** — replay health adjustments and the older + battle-service tick constructors omit source identity. +- Damage-reduction modifier state/provenance: **partially observable** — replay player updates can + carry `FlatDamageReduction` and `PercentDamageReduction`, but the latest corpus frequently omits + the Player-side value even when periodic adjustments exhibit a stable 20%/25% reduction. Effect + execution messages also omit the modified attribute and value, so modifier provenance cannot be + reconstructed completely from `CombatSimEventEffectExecuted`. +- Per-card authoritative apply totals exist: **verified** — `CombatSim.CardStats` carries BurnAdded, + PoisonAdded, and RegenAdded. +- Replay spawn data can describe normal item/skill instances without live Unity entities: + **verified in schema** — `GameSimEventCardSpawned` carries instance, template, type, and owner. +- Existing local replay corpus: **verified present** — 53 payloads; current payload version is not a + game-build stamp, so these are exploratory rather than cross-version golden fixtures. +- Analyzer/server replay corpus: **verified available** — the local Analyzer cache holds 120,137 + raw run bundles (46 GB), while its DuckDB projects 324,723 runs / 1,320,082 battles. The cache is + stale at July 25 for decoded bundles, so a separate read-only production sample fetched the 100 + latest August 4 bundles into a temporary directory and decoded 1,110 battles with zero invalid + payloads. +- Saved payloads deserialize through a BazaarGameShared-only host: **verified** — 53/53 payloads + loaded; the host's dependency closure and runtime assembly scan contain neither Assembly-CSharp + nor UnityEngine. +- The corpus contains the scenario floor needed to assess Shield/Health, overheal, unresolved + source, and multi-source epochs: **partially verified** — it contains all three periodic effects, + Burn with/without Shield, Poison while Shield is present, Regen reaching max Health, and + multi-source battles. Removal/reset classification still needs to distinguish native decay from + cleanse and unexplained mutation. +- Exact/constrained attribution reaches the observable uniqueness ceiling: **verified** — the + latest sample has 19,990 Exact allocation decisions and zero Constrained decisions. Native ticks + erase source identity, so every concurrently owned mixed stack admits multiple allocations and + remains Proportional; no additional complete constraint exists on the retained surfaces. +- Proportional fallback is stable and useful without misleading the UI: **verified** — 61,580 + latest-sample allocation decisions conserve their measured ledgers, repeated reports are + byte-identical, proof remains internal, and the compact UI renders no confidence punctuation. +- Existing-row bilingual UI remains readable across compact and dense tooltips: **unknown**; user + owns closure in the real game. +- Terminal event shape: **verified on the latest 1,110-battle sample** — each battle has exactly one + death event, no combatant has a second death event, and no frame follows the first death event. + `CombatSimPlayerUpdate.IsPlayerDead` is not a reliable substitute in this sample. + +## Acceptance gates + +- **Deserialization:** P0 loads spawn and combat messages from at least one real payload through a + pure BazaarGameShared host. The evaluator's assembly closure contains no Assembly-CSharp or + UnityEngine reference. +- **Non-vacuity:** a corpus run fails if no relevant adjustment/application exists, if zero sources + resolve across the corpus, or if any `CardStats` instance with periodic apply totals lacks a card + identity record without being reported explicitly. +- **Conservation:** for every combatant × status × epoch, attributed plus Unknown Health impact + equals the terminal-adjusted measurement, and attributed plus Unknown Shield consumption equals + terminal-adjusted Burn Shield consumption. Raw adjustments independently partition into + pre-terminal realized plus post-terminal/overkill excluded amounts. Exact equality, no tolerance, + and no cross-ledger cancellation. +- **Coupled reconciliation:** measurement and ownership reconcile separately. Attributed plus + Unknown equals every measured typed Burn/Poison Health and Burn Shield movement exactly; Regen + equals ordered, capped realized healing. Formula diagnostics do not gate admission. +- **Poison bypass:** any Poison-attributed Shield consumption or Shield-dependent Poison formula is + a hard invariant failure. +- **State reconciliation:** the status ledger reconciles frame-by-frame from previous value to + current value. Any unexplained positive or negative state change contaminates the epoch and + prevents Exact/Constrained classification. +- **Non-negative ledger:** no source balance, realized allocation, or residual becomes negative. +- **Uniqueness:** Exact/Constrained is emitted only when a second feasible allocation cannot be + constructed. Candidate-count alone is insufficient. +- **Determinism:** two runs over the same sorted corpus produce byte-identical normalized reports; + no dictionary/hash-set iteration order may affect allocation or display ordering. +- **Apply-side reconciliation:** normalized applications reconcile with per-card `CardStats` totals, + with every mismatch surfaced by battle/source/status. +- **Fixture preservation:** checked-in deterministic fixtures cover single-source, ambiguous + multi-source, removal/reset, unresolved source, Burn with and without Shield, Poison while Shield + exists, Regen clamp, same-frame ordering, and modifier-adjusted typed amounts; proof labels and + values are golden-tested. A genuinely unique multi-source fixture cannot be constructed from the + retained tick surface because source identity is absent; that class reports Constrained = 0. +- **Corpus composition:** before coverage is used as a shipping argument, P1 must report whether the + 53-payload corpus contains Burn with partial/full/no Shield, Poison while Shield is present, + Poison-to-Health, Regen clamp, removal/reset, unresolved-source, and multi-source cases. Missing + classes remain explicit evidence gaps. +- **No exact regression:** later model versions must preserve every previously golden Exact case and + may change one only by an explicit fixture/model-version migration. +- **Coverage:** report realized amounts and frame × status × source allocation-decision counts for + Exact, Constrained, and Proportional, plus measured Unknown gap-frame counts and amounts. Never + fold Proportional into correctness coverage. This finer-grained observer supersedes final-row + counts, which conservatively taint an entire row after any Proportional allocation. +- **Presentation:** proof class is not rendered as punctuation; Unknown contributes no attributed + value. Final geometry/readability is user-accepted. + +## Evidence + +- Realized total schema — decompiled current game types — Codex — **verified**. +- Apply source/target schema — decompiled current game types — Codex — **verified**. +- Missing tick provenance — current replay schema plus legacy tick constructors — Codex — + **verified**. +- Headless entity identity from spawn events — current GameSim schema — Codex — **verified for + normal spawns; transform/runtime completeness pending corpus evidence**. +- Existing projector unsuitable as the corpus normalizer — projector requires live-derived entity + metadata and silently skips unresolved source/target/kind — CC red-team, independently verified. +- Candidate-count sole-source rule is unsound — unresolved/null/non-item sources and untracked + attribute mutations can disappear from the candidate set — CC red-team, independently verified. +- Pool semantics must be empirical/versioned — adjustment schema distinguishes Health/Joy/Shield, + but the current game uses Health/Shield and retains Joy only as legacy schema; + legacy producer shows materially different Burn/Poison/Regen behavior but is not the live server — + CC red-team, independently verified. +- Coupled accounting requirement — user decision — **accepted**: status-stock, Health-impact, and + Shield ledgers must each reconcile before high-confidence attribution. +- Shield/Burn/Poison relationship — user decision — **accepted**: Shield affects Burn, never Poison, + and Shield consumed must not be treated as a 1:1 proxy for Burn damage prevented. +- “Burn decrements by one” and community keyword-page Burn mechanics — **rejected by user as stale**; + replaced by the corpus-verified current formula below. +- `ObservedValue` cannot store realized impact — it is compared to authoritative applied totals — + CC red-team, independently verified. +- Pure replay boundary — retained corpus acceptance harness — **verified**: 53 battles, 18,414 + frames, 2,513 typed periodic adjustments, 0 invalid payloads, no forbidden runtime assemblies. +- P1 pool inventory — retained corpus acceptance harness — **verified**: Burn/Health loss 618 + adjustments (51,171), Burn/Shield loss 531 (95,986), Poison/Health loss 472 (13,452), + Regen/Health gain 892 (117,786), and zero Poison/Shield adjustments. +- P1 scenario inventory — retained corpus acceptance harness — **verified**: 1,111 Burn ticks with + opening Burn+Shield, 262 Poison-to-Health ticks while Shield was present, 52 Regen frames + reaching max Health, and multi-source battle counts of Burn 27 / Poison 15 / Regen 33. +- Current Burn Shield bridge — executable corpus gate — **verified on 1,111/1,111 + observable ticks**: + `ShieldConsumed = min(ShieldBefore, floor(BurnBefore / 2))` and + `HealthDamage = max(0, BurnBefore - 2 * ShieldBefore)`. +- Expanded current Burn Shield bridge — latest 100 production bundles / 1,110 battles — **failed as + a universal rule**: 25,510/26,073 observable ticks matched and 563 mismatched. The earlier 53-battle + sample was insufficient to prove the bridge complete. +- Expanded Burn counterexample classification — **verified**: 454/563 bridge mismatches carry + `IsDamageReduced` (240 Health-only, 191 Shield-only, 23 both); 109 do not. A formula mismatch is + therefore not evidence that the typed Burn adjustment is invalid, and the bridge formula must not + gate whether an observed pool movement is booked. +- Explicit damage-reduction correlation — latest 100 production bundles / 1,110 battles — + **verified**: for every Burn mismatch with an observable 20%/25% + `PercentDamageReduction` (89/89), applying the percentage separately to the pre-reduction Health + and Shield adjustments and flooring each result reproduces the typed amounts. The same rule + reproduces all 54/54 Poison ticks with observable 20%/25% reduction. Poison's + `IsDamageReduced` flag remains false in these cases, so that flag is not a complete modifier + signal. +- Hidden Player-side reduction — latest production sample — **inferred, not source-verifiable**: + the remaining mismatches concentrate on Player frames whose reduction attributes are absent, but + their repeated 75%/80% ratios match the explicit opponent-side behavior. The typed pool movement + is still authoritative; the missing modifier value/source prevents a complete baseline-damage or + prevented-damage reconstruction. +- Current clean Burn decay — executable corpus gate — **verified on 1,019/1,019 eligible ticks** + (92 same-frame apply/heal cases excluded from formula admission): + `decay = max(1, floor(BurnBefore * 0.03))`. +- Poison tick — executable corpus gate — **verified on 472/472 ticks**: use opening Poison, except + frame zero applies Poison before its immediate first tick. Poison never touches Shield. +- Expanded current Poison tick — latest 100 production bundles / 1,110 battles — **failed as a + universal rule**: 10,362/10,503 ticks matched and 141 mismatched; Poison still produced zero Shield + adjustments. +- Regen tick — executable corpus gate — **verified on 820/820 source-status ticks**: attempted Regen + equals opening HealthRegen. The 72 skipped `EDamageType.Regen` adjustments are all on the player, + recur at the native 20-frame tick cadence, have zero status `HealthRegen`, and have no + `PlayerRegenApply`; they are player intrinsic/base Regen rather than an item/skill status source. + Their 536 attempted / 523 realized healing is excluded from both attribution and the 48,790 + source-status denominator. +- Expanded current Regen tick — latest 100 production bundles / 1,110 battles — **failed as a + universal rule**: 11,616/11,627 evaluated source-status ticks matched and 11 mismatched. The new + sample also contains substantially more status-absent native Regen. +- Typed adjustment pool semantics — current schema plus decompiled native UI consumers — + **verified**: the game routes signed `CombatSimPlayerHealthAdjustment.Amount` directly to the + `AttributeChanged` Health/Shield pool and preserves `DamageType`. Negative Burn/Poison Health and + Burn Shield adjustments are authoritative applied pool movements even when a candidate formula + fails. Positive Regen remains special because accepted healing may be capped by current MaxHealth, + so realized Regen still requires the ordered Health ledger. +- Ordered Health ledger — executable corpus gate — **verified on 3,169/3,171 relevant frames**: + apply MaxHealth delta first, replay typed Health adjustments in list order, never clamp losses, + and cap each positive adjustment at current MaxHealth. The two unreconciled frames remain Unknown. +- Shield ledger — executable corpus gate — **verified on 3,171/3,171 relevant frames**. +- Model conservation — retained corpus acceptance harness — **verified**: attributed totals never + exceed reconciled realized totals; Poison/Regen Shield attribution is hard-failed. +- Model determinism — repeated inventory/model build and a second process — **verified**: + byte-identical normalized reports. +- Apply-side CardStats reconciliation — retained corpus inventory — **verified with surfaced + residuals**: Poison reconciles exactly; inferred Regen is short 3 and Burn short 34 across the + corpus, so those affected ownership epochs cannot be promoted to Exact. +- Focused rule preservation — PostCombatImpact tests — **verified**: single-source Burn bridge, + first-tick Poison ordering, mixed-source proportional ownership, Regen clamp, same-frame + damage/regen accounting, Unknown initial status, projector attachment, and bilingual formatting. +- Versioned model coverage — 53-payload corpus — **verified**: Burn Health 51,131/51,171 (99.92%), + Burn Shield 95,986/95,986 (100%), Poison Health 13,450/13,452 (99.99%), and Regen realized Health + 46,197/48,790 (94.69%). V2 retrospectively assigns 4,950 opening-state realized healing only + across item/skill sources independently observed applying Regen elsewhere in the completed combat; + every such assignment remains Proportional in the internal model. +- Expanded versioned-model coverage — latest 100 production bundles / 1,110 battles — **failed the + existing acceptance gate**: Burn Health 1,243,804/1,310,646 (94.90%), Burn Shield + 384,831/429,593 (89.58%), Poison Health 1,017,544/1,069,514 (95.14%), and Regen realized Health + 698,112/757,432 (92.17%). The model remains conservative, but the 53-battle near-perfect + Burn/Poison percentages were not representative. +- V3 measurement/ownership split — latest 100 production bundles / 1,110 battles — **verified**: + Burn Health 1,302,284/1,310,646 (99.36%), Burn Shield 425,930/429,593 (99.15%), Poison Health + 1,060,375/1,069,514 (99.15%), and Regen realized Health 714,653/758,881 (94.17%). Attributed plus + Unknown equals the measured amount exactly for all four ledgers; Poison Shield remains zero. +- V3 proof-class non-regression — latest 100 production bundles — **verified**: Exact results rose + from 1,328 in V2 to 1,365 in V3; Exact Health rose from 895,762 to 940,966 and Exact Shield from + 123,544 to 130,242. Proportional ownership remains explicit in the model and is not promoted to + Exact merely because the measured pool amount is exact. +- V3 allocation-decision proof coverage — latest 100 production bundles — **verified**: 81,570 + frame × status × source decisions comprise 19,990 Exact, 0 Constrained, and 61,580 Proportional. + Exact/Proportional Health amounts are Burn 375,365/926,919, Poison 556,825/503,550, and Regen + 58,366/656,287; Burn Shield is 142,714/283,216. Measured Unknown spans 3,188 gap frames: Burn 210, + Poison 70, and Regen 2,908. Every proof cross-tab reconciles to the attributed total. +- Constrained observability ceiling — model structure plus latest corpus — **verified**: a tick + exposes only aggregate status and pool movement. Once two resolved owners coexist, transferring + one unit of realized impact between them yields a second feasible allocation; CardStats validates + apply totals but adds no tick-source equation. Therefore zero Constrained decisions is the + evidence-backed maximum on the retained surfaces, not an omitted proof category. +- Static Regen candidate fallback — latest 100 production bundles — **verified**: among status-source + gaps, 15,694 healing had one or more item/skill entities with an explicit positive + `RegenApplyAmount`; 40,115 had no event, CardStats identity, or static attribute candidate and + remains Unknown. Intrinsic/status-absent Regen had zero such candidates in this sample and 56,678 + realized healing is excluded from the item/skill denominator. +- V3 determinism — latest 100 production bundles — **verified across two processes**: normalized + reports are byte-identical with SHA-256 + `bcf8187338b7f397d685f2dfb9455922275fae5589a9e0db5cef883437dd2653`. +- V3 local replay gate — 59 current V5 replays / 20,818 frames — **verified**: Burn Health + 57,493/57,507 (99.98%), Burn Shield 97,772/98,039 (99.73%), Poison Health 15,725/15,725 (100%), + and Regen realized Health 55,573/56,288 (98.73%); all conservation gates passed. +- V3 refreshed local replay gate — 62 current V5 replays / 22,016 frames — **verified**: Burn Health + 82,460/82,474 (99.98%), Burn Shield 120,530/120,797 (99.78%), Poison Health 15,851/15,851 (100%), + and Regen realized Health 103,147/103,862 (99.31%); all conservation and proof-cross-tab gates + passed. +- V4 terminal Regen correction — latest 100 production bundles / 1,110 battles — **verified**: + 263 death-event frames contain 15,869 capped Regen movement; only 1,387 occurs before the lethal + threshold and 14,482 occurs after it. V4 excludes the latter while retaining same-frame rescue + when no death event exists. Regen coverage is 702,230/745,285 (94.22%), with 43,055 remaining + Unknown; the main provenance-free source-status gap is 38,942. +- V5 terminal periodic measurement — latest 100 production bundles / 1,110 battles — **verified**: + death-frame Burn Health raw/effective/overkill is 145,563/62,201/83,362 and Poison is + 127,577/68,298/59,279. Burn Shield raw is 12,773; treating each adjustment independently would + retain only 118, while the evidence-backed atomic Burn group retains 11,928 and excludes 845 + belonging to Burn groups that began after an earlier lethal effect. No battle has a later frame or + a second death event. +- V5 order sensitivity and atomic-group admission — **verified**: reversing the full adjustment + list changes death-frame Burn Health from 62,201 to 70,438 and Burn Shield from 118 to 12,632, so + global reversal is not a valid rule. Across all 62,266 periodic frames the corpus has no frame + with more than two negative Burn pool adjustments; observed Burn Health/Shield pairs are + contiguous and represent one tick. The retained harness independently gates raw minus + terminal-excluded equals the V5 measured total. +- V5 expanded coverage — latest production sample — **verified**: Burn Health + 1,219,514/1,227,284 (99.37%), Burn Shield 425,085/428,748 (99.15%), Poison Health + 1,001,417/1,010,235 (99.13%), and Regen 702,230/745,285 (94.22%). The lower Burn/Poison + denominators are intentional removal of overkill, not lost attribution; attributed plus Unknown + still equals every terminal-adjusted ledger exactly. +- V5 refreshed local replay gate — 62 current V5 replays / 22,016 frames — **verified**: terminal + overkill is Burn 19,711 and Poison 1; atomic post-earlier-lethal Burn Shield exclusion is 1. Final + coverage is Burn Health 62,749/62,763 (99.98%), Burn Shield 120,529/120,796 (99.78%), Poison + 15,850/15,850 (100%), and Regen 97,579/98,294 (99.27%). +- V5 determinism, repository validation, and local replacement — **verified**: the expanded report + is byte-identical across processes with SHA-256 + `faf5b7fa464c16062ee973112296dbb4492bf3047af3aa65e9ec624d6e4a62e1`; 149/149 focused tests, + the complete repository suite, both expanded/local corpus gates, format check, and final Debug + build passed. All four installed plugin DLLs are byte-identical to the build outputs; the main + `BazaarPlusPlus.dll` SHA-256 is + `50f86d9c3f23396b62405c91dd4d79c3c4b4befce52977e4df9a970c137b80b5`. +- V5 reproducibility archive — **verified**: the retained 76 KB evidence snapshot records the exact + 100 bundle names/SHA-256 values, 1,110 sorted battle IDs, schemas, rule/terminal diagnostics, + attribution/Unknown totals, and the 70 MB full-report hash. The corresponding 39 MB raw sample is + pinned outside Git under the Analyzer cache. Re-running the harness from that durable copy + reproduces the evidence snapshot byte-for-byte and the full report hash + `faf5b7fa464c16062ee973112296dbb4492bf3047af3aa65e9ec624d6e4a62e1`. +- Remaining Regen boundary — 53-payload corpus — **verified**: 2,593 source-status realized healing + has no CombatSim apply event, periodic CardStats identity, or existing display source row. It stays + Unknown; attaching it would require synthesizing a speculative source. Separately, 523 intrinsic + player Regen remains outside the source-status denominator and 7 healing is excluded by an + unreconciled Health-ledger frame. +- Opening GameSim periodic provenance — 53-payload corpus — **verified absent**: opening GameSim data + contains zero Burn/Poison/Regen apply actions, so passing it into the runtime attribution model + would not close the remaining source gap. +- Final stacked same-row presentation — user in The Bazaar Recap — **pending**. +- V3 repository validation and local replacement — **verified**: 137/137 focused tests, the full + repository suite, both corpus gates, format check, and final Debug build passed with zero build + warnings/errors. All four installed plugin DLLs are byte-identical to their build outputs; the + main `BazaarPlusPlus.dll` SHA-256 is + `6debd87580b0dadccce7e7d5b5bcaec26527233fa401100ca376707bf41234ea`. +- Completion-audit validation and local replacement — **verified**: added explicit reset-epoch and + unresolved-source fixtures; 139/139 focused tests and the complete repository suite passed. + The expanded proof report is byte-identical across processes with SHA-256 + `be38dcfc7bba16f822717d523490bd79fa7196c6c07a7d8d37b7ea59b91b0009`. Format check and the final + Debug build passed with zero warnings/errors; all four installed plugin DLLs match build output, + with main `BazaarPlusPlus.dll` SHA-256 + `e9254f25beac4c4d1d8ec5451c139ba4ec53e2456f00795955da1236ca72da5b`. + +## Implementation boundaries + +- The corpus harness may be detailed because it is offline evidence infrastructure. The shipped + Combat Impact model must remain a small pure bounded-pass rule set over one `CombatSim`; do not + ship corpus I/O, rule search, a general constraint solver, or replay-report machinery in the + runtime path. Prefer the simplest rule set that clears the evidence gates; do not optimize + coverage by making the production model opaque. +- Do not drive the corpus through `CombatImpactProjector`; attribution starts from a pure normalized + replay model, then Combat Impact consumes its result. +- P0/P1 use `CombatReplayPayloadStore` payload decoding plus direct + `MessagePackSerializer.Deserialize` with + `MessagePackConfig.Options`; do not call `CombatReplayLoader`, whose return type is + `CombatSequenceMessages` from Assembly-CSharp. +- Keep the attribution engine pure and feature-owned. Reuse a BazaarGameShared-only replay decoder + seam in the retained acceptance harness; do not add a temporary standalone diagnostic path. +- Add a future replay capture fingerprint for game/schema build. Existing unstamped payloads remain + exploratory and cannot become cross-version goldens without anonymized, version-pinned fixtures. + +## Diffusion sequence + +1. **P0 / boundary:** deserialize one real payload's spawn and combat messages in the pure host; + prove dependency closure and fail loudly on vacuous input. +2. **P1 / observation inventory:** scan the sorted corpus and report raw status adjustments by pool, + state transitions, apply/remove/modify actions, source resolution, CardStats reconciliation, and + scenario composition. No attribution rule yet. +3. **M1 / strict proof:** implement epochs and uniqueness proof. Emit only Exact/Constrained or + Unknown; add golden fixtures and corpus invariants. +4. **M2 / constrained iteration:** add one evidence-backed constraint at a time. Accept it only if + Exact+Constrained coverage rises and all earlier gates remain green. +5. **M3 / approximation:** if ambiguity remains, add deterministic Proportional allocation and keep + its proof class in the model; never promote it into correctness coverage. +6. **Integration:** attach separate realized/confidence fields to existing Burn/Poison/Regen groups, + render on the same row, build and replace the local Debug DLL, then wait for user visual closure. + +## Fuses + +- Substantial rounds without new goal-level evidence: 0 / 3. +- Repeated unchanged observation from the same probe: 0 / 2. The user supplied a new terminal-order + hypothesis; V4/V5 produced material corpus evidence and changed the realized-impact definition. +- Model iteration stop: two consecutive accepted-or-rejected constraint rounds without increased + Exact+Constrained realized-amount coverage. +- Corpus stop: if P1 has fewer than two required scenario classes or fewer than ten relevant + battles, do not infer model quality from percentages; obtain more corpus or restrict the claim. +- Proof stop: if unexplained state transitions prevent a complete ledger, keep those epochs Unknown + rather than weakening Exact/Constrained. + +## Next decision + +`periodic-impact-v6` is implemented and corpus-validated. The measurement ledger books authoritative +typed post-modifier adjustments, then applies a small terminal rule: on the one death-event frame, +ordered Health loss stops at zero, post-terminal periodic groups and Regen are excluded, and a +contiguous Burn Health/Shield pair remains one atomic tick. Raw, effective, overkill, reversed-order, +and atomic-group diagnostics prevent the production rule from validating only against its own copy. +The ownership ledger now accepts Regen fallback candidates only from observed apply executions or +positive `CardStats.RegenAdded`; a static `RegenApplyAmount` proves capability, not contribution. +Stable spawn-positive Regen with no transition or apply evidence remains in total healing +conservation but is excluded from source-attribution coverage and never appears on an item/skill row. + +On the pinned 1,110-battle sample, 54,230 reconciled healing is stable spawn baseline; V6 attributes +zero of it. Source-attributable Regen coverage is 686,099 / 691,055 = 99.28%, leaving 4,956 Unknown. +The remaining candidates concern source precision rather than measured totals: initial mixed state, +same-frame decay plus apply, whole-combat weights for ambiguous co-appliers, and absent MaxHealth +evidence. They must remain Proportional/Unknown unless a corpus round supplies an independent +per-source constraint; do not turn them into Exact by formula. The next observer is the user's +in-game numeric/visual smoke test. Do not launch the game automatically, and do not commit, merge, +or update a PR without explicit instruction. + +## Iteration log + +- 2026-08-04: Goal created. Evidence ordering started at the real saved-replay boundary; no model or + UI code was added. +- 2026-08-04: Read-only CC red-team completed. Independently verified four design blockers: the live + projector is not a valid headless normalizer, sole-source-by-absence is unsound, realized pool + semantics need an explicit versioned policy, and realized values require separate model fields. + Revised the sequence to P0/P1 before attribution and strengthened Exact to a positive uniqueness + proof over a reconciled state epoch. No implementation code added. +- 2026-08-04: Paused after three consecutive goal turns without the required post-review user + confirmation. No implementation code was added. Resume at P0 once the user confirms or amends the + candidate pool policy and proof contract. +- 2026-08-04: User required accounting-style reconciliation against actual damage and Shield + consumption. Added coupled status-stock and realized-pool ledgers plus full frame pool balancing. + Separated current-facing documented rules from live-server facts; P1 must verify the mechanics + observed by this replay schema before M1 hard-codes them. No implementation code added. +- 2026-08-04: User corrected two stale assumptions: current gameplay has no Joy attribute, and Burn + no longer follows the old decrement-one rule. Removed Joy from the accounting policy and removed + all pre-P1 Burn/Poison/Regen mechanics as axioms. Added a hard rule-admission gate: complete corpus + reconciliation plus counterexample scan. No implementation code added. +- 2026-08-04: User confirmed the revised accounting/proof contract and resumed the goal. Added a + retained pure corpus harness. P0 loaded all 53 saved payloads without Assembly-CSharp/UnityEngine; + P1 inventoried 18,414 frames and 2,513 periodic adjustments with zero invalid payloads. +- 2026-08-04: First P1 counterexample scan found compact current-version mechanics: Poison bypasses + Shield; clean Burn decays by `max(1, floor(3%))`; two Shield points prevent one Burn Health damage; + and uncluttered Poison/Regen ticks use the opening status value. Burn bridge and clean decay had + zero counterexamples in 717 and 944 observable ticks respectively. Same-frame ordering and + multi-source application ambiguity remain active evidence questions. +- 2026-08-04: User clarified that Shield affects Burn but not Poison, and that Shield consumption is + not numerically interchangeable with Burn damage prevented. Replaced the 1:1 two-ledger model + with separate status-stock, Health-impact, and Shield ledgers connected by a versioned Burn bridge + rule. No implementation code added. +- 2026-08-04: Explained that P0/P1 discover version-sensitive combat formulas while the user fixes + only stable accounting semantics. Paused after three consecutive resumed-goal turns without the + required final design confirmation. Resume directly at P0 after confirmation. +- 2026-08-04: User confirmed the discovery boundary and required the final Combat Impact integration + to be a simple effective rule set. Resumed at P0 and added a runtime-complexity boundary: offline + evidence may be rich, shipped evaluation remains pure and single-pass. +- 2026-08-04: Completed executable P1 gates. Current rules reconciled all 1,111 Burn bridge ticks, + all 1,019 eligible Burn decays, all 472 Poison ticks, all 820 source-status Regen ticks, all 3,171 + Shield ledgers, and 3,169/3,171 Health ledgers. Two unexplained Health frames are explicitly + excluded instead of weakening conservation. +- 2026-08-04: Implemented `periodic-impact-v1`: ordered pool accounting plus a small ownership + ledger. A unique source stays Exact; mixed stacks use deterministic proportional allocation and + `≈`; unresolved stacks remain Unknown. Attached realized damage/healing and Burn Shield + consumption to the existing row without a new section. Corpus coverage is 99.92% Burn Health, + 100% Burn Shield, 99.99% Poison Health, and 84.54% Regen Health. +- 2026-08-04: Focused PostCombatImpact tests and the complete repository test suite passed. The + retained corpus harness also passed its non-vacuity, current-rule, Poison-bypass, conservation, + assembly-closure, and determinism gates. Final in-game acceptance remains user-owned. +- 2026-08-04: Replaced the clipped inline realized-impact suffix with a dedicated compact metric + annotation beneath the authoritative total. Ordinary group rows keep their existing layout; + periodic rows reuse the same 44/48px header and a 0.62-scale, right-aligned secondary label. + Focused tests passed 131/131, format check and Debug build passed, and the built/local plugin DLL + SHA-256 matched (`b9d33afb7c8fccf34dca382e3e5e14fe0e039701b9db0d1a07a402d662db35f9`). +- 2026-08-04: Corrected the earlier interpretation of 72 Regen adjustments with zero status Regen. + Corpus evidence identifies them as player intrinsic/base Regen: player-only, no apply event, and + recurring every 20 frames. They total 536 attempted / 523 realized and were already excluded from + the 48,790 source-status denominator, so they do not explain the 7,543 attribution gap. Confirmed + that the model does consume both `PlayerRegenApply` source events and ordered `EDamageType.Regen` + adjustments; the unresolved question is opening-state/event provenance, not missing tick input. +- 2026-08-04: Replaced the secondary realized-damage `dmg`/`伤害` text with the game's native + `DamageAmount` icon while preserving the amount and approximation marker. Focused tests passed + 131/131, format check and Debug build passed, and the built/local plugin DLL SHA-256 matched + (`aeebe5835d4b2f62ce9a39805a46b44cb456b3481bddab852c3e4b8ebabdee77`). +- 2026-08-04: Classified the full 7,543 source-status Regen gap. Opening GameSim contributes no + periodic apply events. Added `periodic-impact-v2`: an initial Unknown Regen stack may be allocated + retrospectively only across item/skill sources independently observed applying Regen in that + combat, and is always marked `≈`. This recovered 4,950 realized healing and raised coverage from + 84.54% to 94.69%. The residual 2,593 has no event, CardStats identity, or existing source row and + remains Unknown. The complete suite passed, including 133/133 focused PostCombatImpact tests; the + retained corpus gates and 94% Regen regression floor passed. Format check and the final Debug + build passed; built/local plugin DLL SHA-256 matched + (`f77e82763945a59651d7b747b17098cc44c93ede096084efc98beb5e4519af21`). +- 2026-08-04: User superseded the compact presentation contract: keep Proportional proof in the + model but omit `≈` punctuation. Removed Ellipsis from all Combat Impact-owned TMP labels. Replaced + the fixed-width two-object periodic metric stack with one multiline TMP block whose preferred + width may consume free header space and whose two lines auto-size together inside the row height. + Burn Shield consumption now uses the native Shield icon. Focused tests passed 133/133, format + check and Debug build passed, and the built/local plugin DLL SHA-256 matched + (`cad29ba6cb8ecd368560cba795617fedbc6e51bb13a706111bb05dfa34829054`). +- 2026-08-04: User challenged whether the 53-battle corpus was representative and pointed to the + Analyzer cache. Verified 120,137 locally cached raw bundles (46 GB) and 1,320,082 projected + battles, but the local drain was stale. Used the existing read-only Analyzer credentials to fetch + the latest 100 August 4 run bundles (41 MB) into a temporary directory; the retained harness + decoded 1,110 battles / 339,649 frames with zero invalid payloads. This expanded sample falsified + the assumption that only Regen had material gaps: model coverage was 94.90% Burn Health, 89.58% + Burn Shield, 95.14% Poison Health, and 92.17% Regen Health. The strict corpus gate correctly failed; + no runtime attribution rule was weakened or changed. +- 2026-08-04: Classified the expanded counterexamples. Most Burn bridge failures are explicitly + damage-reduced, while the native UI still applies every typed adjustment directly to its named + Health/Shield pool. Revised the next candidate to separate authoritative pool measurement from + uncertain source ownership and to retain unexplained residual in an internal suspense account. + This can make Burn/Poison/Burn-Shield pool totals exact without falsely claiming exact per-source + attribution. No shipped runtime code or local plugin DLL was changed. +- 2026-08-04: User identified received-damage modifiers as a missing mechanism. Extended the + retained corpus report with opening/closing flat and percentage damage-reduction state. Explicit + 20%/25% reduction explains 89/89 affected Burn bridge mismatches and 54/54 affected Poison tick + mismatches after per-pool flooring. Player-side reduction state is often absent despite matching + output ratios, and Poison does not set `IsDamageReduced`; therefore V3 must consume typed actual + adjustments and treat modifier reconstruction only as diagnostics. No shipped runtime code or + local plugin DLL was changed. +- 2026-08-04: Implemented `periodic-impact-v3`. Separated authoritative typed pool measurement from + source ownership, reanchored frame state from transition evidence, accepted same-frame Regen + decay/order through the Health ledger, and added a conservative static `RegenApplyAmount` + candidate fallback. On the latest 1,110-battle sample, coverage reached 99.36% Burn Health, + 99.15% Burn Shield, 99.15% Poison Health, and 94.17% Regen with exact conservation; aggregate + Exact evidence improved over V2. The remaining 40,115 Regen gap has no retained provenance and + remains Unknown. +- 2026-08-04: Repeated the expanded corpus in a second process and obtained a byte-identical report. + The 59-replay local corpus, 137 focused tests, full repository suite, format check, and final Debug + build all passed. Replaced all four local plugin DLLs and verified byte equality with build + outputs. Per user instruction, did not launch The Bazaar; final in-game acceptance remains pending. +- 2026-08-04: Completion audit found that final source-row proof totals understated Exact decisions + and did not explicitly report the empty Constrained class. Added corpus-only allocation diagnostics + at frame × status × source granularity plus proof-class count/amount conservation gates. The + latest 1,110-battle sample contains 19,990 Exact, 0 Constrained, and 61,580 Proportional decisions. + Zero Constrained is the observable ceiling: mixed native ticks carry no source equation, so every + mixed allocation has a second feasible solution. This changes evidence reporting only, not + user-visible attribution values. +- 2026-08-04: Added golden reset-epoch and unresolved-source fixtures and refreshed the local corpus + to 62 battles. Focused tests passed 139/139, the full repository suite passed, the expanded report + was byte-identical across processes, and the final Debug build/local replacement matched. The + original epoch-count coverage wording was refined to allocation-decision counts because the + shipped result intentionally aggregates epochs into one source row. Final in-game acceptance + remains user-owned and pending. +- 2026-08-04: The same final perceptual probe remained pending for three consecutive goal turns. + All discrete model, corpus, regression, build, installation, and source-information-loss criteria + are already verified, while the user explicitly owns real-game acceptance and asked Codex not to + launch the game. Triggered the repeated-observation fuse and marked the goal blocked pending a + user screenshot or acceptance result; no further local changes were made. +- 2026-08-04: User hypothesized that Regen adjustments occurring after lethal damage in the same + frame should not count. Decompiled playback confirms ordered adjustments precede the terminal + death event, and the expanded corpus found 14,482 capped Regen movement after Health was already + non-positive. Implemented V4 with death-event-aware ordered Regen and three terminal/rescue + goldens; source coverage rose slightly because the denominator now excludes ineffective healing. +- 2026-08-04: Ran an independent Claude Opus review of the realized-impact invariant. Its strongest + finding was the symmetric terminal gap for Burn/Poison and Burn Shield. Corpus instrumentation + measured 83,362 Burn and 59,279 Poison overkill on 1,110 terminal frames, with no frames after + death. Implemented V5 Health capping and sticky within-frame terminal state. +- 2026-08-04: Opus then challenged the causal meaning of cross-pool list order. Reversing the list + materially changed Burn results, while real frames show at most one contiguous Burn Health/Shield + pair. Revised V5 so that pair is one atomic tick: a lethal Burn retains its paired Shield + consumption, but a Burn group beginning after an earlier lethal effect is skipped. Added raw, + effective, overkill, reversed-order, and atomic-group corpus partitions plus ten terminal-order + goldens. The expanded and local corpus gates pass with exact terminal-adjusted conservation. +- 2026-08-04: User required the analysis to remain reproducible after future algorithm changes. + Added automatic compact evidence snapshots with source-artifact hashes, battle IDs, complete + report hash, rule/terminal measurements, proof coverage, and Unknown totals. Pinned the exact 100 + production bundles in the Analyzer cache and proved a clean rerun from that durable copy produces + byte-identical evidence and the same 70 MB report hash; future iterations no longer start from + corpus discovery or rule reconstruction. +- 2026-08-04: Fable identified an observation asymmetry: the corpus harness sees spawn + `HealthRegen`, while the shipped model sees only `CombatSim` transitions. A new partition proved + all 38,942 measured `StatusStateAbsent` Unknown was opponent spawn-positive, had no Regen + transition anywhere in combat, and ticked exactly at the opening value. The same stable-baseline + context exposed 15,288 healing incorrectly assigned through static-only fallback candidates. +- 2026-08-04: Implemented `periodic-impact-v6`. Removed static `RegenApplyAmount` as attribution + evidence, retained execution/CardStats fallbacks, and added a hard corpus gate that stable spawn + baseline must attribute zero to item/skill rows. Raw Regen attribution fell by 16,131 and Unknown + rose by the same amount, preserving exact total conservation. After excluding 54,230 independently + reconciled baseline healing from the source-attribution denominator, honest coverage is 99.28% + (686,099 / 691,055). The persisted V6 evidence and full report are byte-identical across two runs. +- 2026-08-04: Final V6 validation passed: 149 focused PostCombatImpact tests, the complete repository + test suite, format check, the pinned 1,110-battle corpus with zero invalid payloads, and a zero-warning + Debug build. The checked-in evidence SHA-256 is + `9ca232d60e5a30ae218400619092af71aeb1e8ab915473e5b5a71fa6e3ee0997`; its complete report hash is + `eb129d33bdb2a566c9bc87a8f8ce5c77d0bbda7ae5ace585f94808a3bcc3c012`. All four installed plugin + DLLs match the build outputs; the main DLL SHA-256 is + `603c92ac6cb1bbf7a70eff3a4df49032ea75e0c5877d090428b86ee0c8d3edda`. The game was not launched. diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs index f928e13c..92382387 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactMetricFormatter.cs @@ -101,6 +101,52 @@ internal static string Group( return string.Join(" · ", parts); } + internal static string PeriodicImpact( + CombatImpactGroup group, + bool chinese, + string? damageMarker = null, + string? shieldMarker = null + ) + { + var impact = group.PeriodicImpact; + if (impact == null) + return string.Empty; + + var parts = new List(); + if (impact.HealthAmount > 0) + { + var amount = Integer(impact.HealthAmount); + var isRegen = + group.Kind == CombatImpactKind.AttributeChange + && group.NativeAttributeKey == "RegenApplyAmount"; + parts.Add( + isRegen + ? chinese + ? $"治疗 {amount}" + : $"{amount} healed" + : string.IsNullOrWhiteSpace(damageMarker) + ? chinese + ? $"伤害 {amount}" + : $"{amount} dmg" + : $"{amount} {damageMarker}" + ); + } + + if (impact.ShieldAmount > 0) + { + var amount = Integer(impact.ShieldAmount); + parts.Add( + string.IsNullOrWhiteSpace(shieldMarker) + ? chinese + ? $"耗盾 {amount}" + : $"{amount} shield consumed" + : $"{amount} {shieldMarker}" + ); + } + + return string.Join(" · ", parts); + } + internal static string Target(CombatImpactGroup group, CombatImpactTarget target, bool chinese) { var count = $"×{target.Count}"; diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs index 4b97ebb3..decfdbc2 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs @@ -62,6 +62,29 @@ internal enum CombatImpactAuthoritativeBasis ApplicationCount, } +internal enum CombatImpactPeriodicKind +{ + Burn, + Poison, + Regen, +} + +internal enum CombatImpactPeriodicProof +{ + Exact, + Constrained, + Proportional, +} + +internal readonly record struct PeriodicImpactKey(string SourceId, CombatImpactPeriodicKind Kind); + +internal sealed record CombatImpactPeriodicImpact( + int HealthAmount, + int ShieldAmount, + CombatImpactPeriodicProof Proof, + string ModelVersion +); + internal sealed record CombatImpactEntity( string Id, string Name, @@ -182,6 +205,8 @@ IReadOnlyList Targets internal IReadOnlyList TriggerSources { get; init; } = []; + internal CombatImpactPeriodicImpact? PeriodicImpact { get; init; } + internal bool HasDivergentTargetCoverage => UnresolvedTargetCount > 0 || AuthoritativeMetric is { Basis: CombatImpactAuthoritativeBasis.TotalAmount } total diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs index c3ef709b..a04b948f 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs @@ -167,9 +167,69 @@ IReadOnlyDictionary entities var report = CombatImpactAggregator.Aggregate( new CombatImpactProjectionInput(entities, events, useCounts, authoritative) ); + report = AttachPeriodicImpacts( + report, + PeriodicEffectAttribution.Project(simulation, entities) + ); return AttachTriggerSources(report, triggerOccurrences, entities); } + private static CombatImpactReport AttachPeriodicImpacts( + CombatImpactReport report, + IReadOnlyDictionary impacts + ) + { + if (impacts.Count == 0) + return report; + + return report with + { + Sources = report + .Sources.Select(source => + source with + { + Groups = source + .Groups.Select(group => + { + var kind = PeriodicKind(group); + return + kind.HasValue + && impacts.TryGetValue( + new PeriodicImpactKey(source.Entity.Id, kind.Value), + out var impact + ) + ? group with + { + PeriodicImpact = impact, + } + : group; + }) + .ToArray(), + } + ) + .ToArray(), + }; + } + + private static CombatImpactPeriodicKind? PeriodicKind(CombatImpactGroup group) => + group.Surface != CombatImpactEventSurface.AppliedEffect + ? null + : group.Kind switch + { + CombatImpactKind.Burn + when group.NativeAttributeKey + == CombatImpactAggregator.NativeKey(CombatImpactKind.Burn) => + CombatImpactPeriodicKind.Burn, + CombatImpactKind.Poison + when group.NativeAttributeKey + == CombatImpactAggregator.NativeKey(CombatImpactKind.Poison) => + CombatImpactPeriodicKind.Poison, + CombatImpactKind.AttributeChange + when group.NativeAttributeKey == "RegenApplyAmount" => + CombatImpactPeriodicKind.Regen, + _ => null, + }; + private static CombatImpactReport AttachTriggerSources( CombatImpactReport report, IReadOnlyCollection occurrences, diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/PeriodicEffectAttribution.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/PeriodicEffectAttribution.cs new file mode 100644 index 00000000..849fe8fd --- /dev/null +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/PeriodicEffectAttribution.cs @@ -0,0 +1,1113 @@ +#nullable enable +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Domain.Effect; +using BazaarGameShared.Infra.Messages.CombatSimEvents; +using BazaarGameShared.Infra.Messages.Shared; + +namespace BazaarPlusPlus.Game.PostCombatImpact.Data; + +internal static class PeriodicEffectAttribution +{ + internal const string ModelVersion = "periodic-impact-v6"; + + // The shipped model is deliberately a small accounting pass: + // 1. book typed Burn/Poison Health and Burn Shield adjustments as actual pool movement; + // on a death-event frame, Health damage is capped at the remaining positive Health; + // contiguous Burn Health/Shield adjustments are one atomic tick, so a lethal Burn Health + // component does not discard its paired Shield consumption; + // 2. reconstruct accepted Regen through the ordered Health/MaxHealth ledger; + // on a death-event frame, Regen after Health is already non-positive is not realized; + // 3. split each measured result by the status stack currently owned by each source. + // Tick formulas remain corpus diagnostics and never decide whether a typed adjustment is booked. + // Initial Regen with no tick provenance may be redistributed only across item/skill sources + // that the completed combat independently proves applied Regen through an execution or + // CardStats. A static RegenApplyAmount describes capability, not observed contribution. The + // retrospective fallback and every mixed stack retain Proportional proof internally. + + internal static IReadOnlyDictionary Project( + CombatSim simulation, + IReadOnlyDictionary entities, + PeriodicEffectAttributionDiagnostics? diagnostics = null + ) + { + var ledgers = new Dictionary + { + [ECombatantId.Player] = BuildLedger(simulation, ECombatantId.Player), + [ECombatantId.Opponent] = BuildLedger(simulation, ECombatantId.Opponent), + }; + var impacts = new Dictionary(); + var regenFallbackSources = BuildRegenFallbackSources(simulation, entities); + + for (var frameIndex = 0; frameIndex < simulation.Frames.Count; frameIndex++) + { + var frame = simulation.Frames[frameIndex]; + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + var ledger = ledgers[combatant]; + var pools = ReconstructPools( + update, + ledger.Attributes, + frame + .Events.OfType() + .Any(death => death.CombatantId == combatant) + ); + + var reconciledBeforeImpact = new HashSet(); + foreach ( + var status in new[] + { + PeriodicStatus.Burn, + PeriodicStatus.Poison, + PeriodicStatus.Regen, + } + ) + { + if (!ShouldReconcileBeforeImpact(status, pools, ledger, update)) + continue; + ReconcileStatus(frame, update, combatant, status, ledger, simulation, entities); + reconciledBeforeImpact.Add(status); + } + + AttributeFrameImpact( + pools, + ledger, + impacts, + diagnostics, + frameIndex, + combatant, + simulation, + regenFallbackSources[combatant] + ); + + foreach ( + var status in new[] + { + PeriodicStatus.Burn, + PeriodicStatus.Poison, + PeriodicStatus.Regen, + } + ) + { + if (reconciledBeforeImpact.Contains(status)) + continue; + ReconcileStatus(frame, update, combatant, status, ledger, simulation, entities); + } + + ApplyAttributeUpdates(update, ledger.Attributes); + } + } + + return impacts.ToDictionary( + pair => pair.Key, + pair => pair.Value.Build(), + EqualityComparer.Default + ); + } + + private static CombatantLedger BuildLedger(CombatSim simulation, ECombatantId combatant) + { + var attributes = new Dictionary(); + foreach (var frame in simulation.Frames) + { + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + if (update == null) + continue; + foreach (var (attribute, transition) in update.Attributes) + attributes.TryAdd(attribute, transition.PreviousValue); + } + + attributes.TryAdd(EPlayerAttributeType.Burn, 0); + attributes.TryAdd(EPlayerAttributeType.Poison, 0); + attributes.TryAdd(EPlayerAttributeType.HealthRegen, 0); + attributes.TryAdd(EPlayerAttributeType.Shield, 0); + if ( + !attributes.ContainsKey(EPlayerAttributeType.HealthMax) + && attributes.TryGetValue(EPlayerAttributeType.Health, out var health) + ) + { + attributes[EPlayerAttributeType.HealthMax] = health; + } + + var ledger = new CombatantLedger(attributes); + foreach ( + var status in new[] { PeriodicStatus.Burn, PeriodicStatus.Poison, PeriodicStatus.Regen } + ) + { + var initial = ledger.Read(Attribute(status)); + if (initial > 0) + { + ledger.Ownership[status].AddUnknown(initial, PeriodicUnknownOrigin.InitialState); + } + } + return ledger; + } + + private static PoolResult ReconstructPools( + CombatSimPlayerUpdate? update, + IDictionary attributes, + bool combatantDied + ) + { + if (update == null) + return PoolResult.Empty; + + var burnHealth = Loss(update, EDamageType.Burn, EPlayerHealthChangeType.Health); + var burnShield = Loss(update, EDamageType.Burn, EPlayerHealthChangeType.Shield); + var poisonHealth = Loss(update, EDamageType.Poison, EPlayerHealthChangeType.Health); + var regenAttempted = Gain(update, EDamageType.Regen, EPlayerHealthChangeType.Health); + var regenRealized = 0L; + + var hasHealthTransition = update.Attributes.TryGetValue( + EPlayerAttributeType.Health, + out var healthTransition + ); + var hasHealthMaxTransition = update.Attributes.TryGetValue( + EPlayerAttributeType.HealthMax, + out var healthMaxTransition + ); + var hasHealthInLedger = attributes.TryGetValue( + EPlayerAttributeType.Health, + out var healthFromLedger + ); + var hasHealthMaxInLedger = attributes.TryGetValue( + EPlayerAttributeType.HealthMax, + out var healthMaxFromLedger + ); + var hasHealth = hasHealthTransition || hasHealthInLedger; + var hasHealthMax = hasHealthMaxTransition || hasHealthMaxInLedger; + var health = hasHealthTransition ? healthTransition!.PreviousValue : healthFromLedger; + var healthMax = hasHealthMaxTransition + ? healthMaxTransition!.PreviousValue + : healthMaxFromLedger; + var healthReconciled = hasHealth && hasHealthMax; + if (healthReconciled) + { + var closingMax = hasHealthMaxTransition ? healthMaxTransition!.CurrentValue : healthMax; + long cursor = health + (closingMax - healthMax); + var terminalReached = combatantDied && cursor <= 0; + var effectiveBurnHealth = 0L; + var effectiveBurnShield = 0L; + var effectivePoisonHealth = 0L; + for (var index = 0; index < update.HealthAdjustments.Count; index++) + { + var adjustment = update.HealthAdjustments[index]; + if ( + adjustment.DamageType == EDamageType.Burn + && adjustment.Amount < 0 + && adjustment.AttributeChanged + is EPlayerHealthChangeType.Health + or EPlayerHealthChangeType.Shield + ) + { + var groupWasAlive = !combatantDied || !terminalReached; + while ( + index < update.HealthAdjustments.Count + && update.HealthAdjustments[index].DamageType == EDamageType.Burn + && update.HealthAdjustments[index].Amount < 0 + && update.HealthAdjustments[index].AttributeChanged + is EPlayerHealthChangeType.Health + or EPlayerHealthChangeType.Shield + ) + { + var burnAdjustment = update.HealthAdjustments[index]; + if (burnAdjustment.AttributeChanged == EPlayerHealthChangeType.Health) + { + var loss = -(long)burnAdjustment.Amount; + if (groupWasAlive) + { + effectiveBurnHealth += combatantDied + ? Math.Min(loss, Math.Max(0L, cursor)) + : loss; + } + cursor += burnAdjustment.Amount; + if (combatantDied && cursor <= 0) + terminalReached = true; + } + else if (groupWasAlive) + { + effectiveBurnShield += -(long)burnAdjustment.Amount; + } + index++; + } + index--; + continue; + } + if (adjustment.AttributeChanged != EPlayerHealthChangeType.Health) + continue; + if (adjustment.Amount <= 0) + { + var loss = -(long)adjustment.Amount; + var effectiveLoss = + combatantDied && !terminalReached ? Math.Min(loss, Math.Max(0L, cursor)) + : combatantDied ? 0L + : loss; + if (adjustment.DamageType == EDamageType.Poison) + effectivePoisonHealth += effectiveLoss; + cursor += adjustment.Amount; + if (combatantDied && cursor <= 0) + terminalReached = true; + continue; + } + + var accepted = Math.Min(adjustment.Amount, Math.Max(0L, closingMax - cursor)); + cursor += accepted; + if ( + adjustment.DamageType == EDamageType.Regen + && (!combatantDied || !terminalReached) + ) + { + regenRealized += accepted; + } + } + + burnHealth = effectiveBurnHealth; + burnShield = effectiveBurnShield; + poisonHealth = effectivePoisonHealth; + + var closingHealth = hasHealthTransition ? healthTransition!.CurrentValue : cursor; + healthReconciled = cursor == closingHealth; + attributes[EPlayerAttributeType.Health] = SaturatingInt(closingHealth); + attributes[EPlayerAttributeType.HealthMax] = closingMax; + } + + var hasShieldTransition = update.Attributes.TryGetValue( + EPlayerAttributeType.Shield, + out var shieldTransition + ); + var hasShieldInLedger = attributes.TryGetValue( + EPlayerAttributeType.Shield, + out var shieldFromLedger + ); + var shieldReconciled = hasShieldTransition || hasShieldInLedger; + var shield = hasShieldTransition ? shieldTransition!.PreviousValue : shieldFromLedger; + if (shieldReconciled) + { + long cursor = shield; + cursor += update + .HealthAdjustments.Where(adjustment => + adjustment.AttributeChanged == EPlayerHealthChangeType.Shield + ) + .Sum(adjustment => (long)adjustment.Amount); + var closingShield = hasShieldTransition ? shieldTransition!.CurrentValue : cursor; + shieldReconciled = cursor == closingShield; + attributes[EPlayerAttributeType.Shield] = SaturatingInt(closingShield); + } + + return new PoolResult( + healthReconciled, + burnHealth, + burnShield, + poisonHealth, + regenAttempted, + regenRealized + ); + } + + private static void AttributeFrameImpact( + PoolResult pools, + CombatantLedger ledger, + IDictionary impacts, + PeriodicEffectAttributionDiagnostics? diagnostics, + int frameIndex, + ECombatantId combatant, + CombatSim simulation, + IReadOnlyList regenFallbackSources + ) + { + if (pools.BurnHealthDamage > 0 || pools.BurnShieldConsumed > 0) + { + AllocateImpact( + ledger.Ownership[PeriodicStatus.Burn], + PeriodicStatus.Burn, + pools.BurnHealthDamage, + pools.BurnShieldConsumed, + impacts, + diagnostics, + frameIndex, + combatant, + simulation, + regenFallbackSources + ); + } + + if (pools.PoisonHealthDamage > 0) + { + AllocateImpact( + ledger.Ownership[PeriodicStatus.Poison], + PeriodicStatus.Poison, + pools.PoisonHealthDamage, + 0, + impacts, + diagnostics, + frameIndex, + combatant, + simulation, + regenFallbackSources + ); + } + + var openingRegen = ledger.Read(EPlayerAttributeType.HealthRegen); + if (pools.RegenAttempted <= 0) + return; + if (!pools.HealthReconciled) + { + diagnostics?.ObserveGap( + frameIndex, + combatant, + CombatImpactPeriodicKind.Regen, + pools.RegenRealized, + 0, + PeriodicUnknownOrigin.HealthLedgerUnreconciled + ); + return; + } + if (openingRegen <= 0) + { + if (regenFallbackSources.Count == 0) + { + diagnostics?.ObserveGap( + frameIndex, + combatant, + CombatImpactPeriodicKind.Regen, + pools.RegenRealized, + 0, + PeriodicUnknownOrigin.StatusStateAbsent + ); + return; + } + } + AllocateImpact( + ledger.Ownership[PeriodicStatus.Regen], + PeriodicStatus.Regen, + pools.RegenRealized, + 0, + impacts, + diagnostics, + frameIndex, + combatant, + simulation, + regenFallbackSources + ); + } + + private static bool ShouldReconcileBeforeImpact( + PeriodicStatus status, + PoolResult pools, + CombatantLedger ledger, + CombatSimPlayerUpdate? update + ) + { + if (update == null) + return false; + + var hasMeasuredImpact = status switch + { + PeriodicStatus.Burn => pools.BurnHealthDamage > 0 || pools.BurnShieldConsumed > 0, + PeriodicStatus.Poison => pools.PoisonHealthDamage > 0, + PeriodicStatus.Regen => pools.RegenAttempted > 0, + _ => false, + }; + if (!hasMeasuredImpact) + return false; + + var attribute = Attribute(status); + var opening = ledger.Read(attribute); + if (!update.Attributes.TryGetValue(attribute, out var transition)) + return false; + + if (opening <= 0 && transition.CurrentValue > 0) + return true; + + return status == PeriodicStatus.Regen + && pools.RegenAttempted != opening + && pools.RegenAttempted == transition.CurrentValue; + } + + private static void AllocateImpact( + OwnershipLedger ownership, + PeriodicStatus status, + long health, + long shield, + IDictionary impacts, + PeriodicEffectAttributionDiagnostics? diagnostics, + int frameIndex, + ECombatantId combatant, + CombatSim simulation, + IReadOnlyList regenFallbackSources + ) + { + var healthShares = ownership + .SplitImpact(health, out var unknownHealth) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var shieldShares = ownership.SplitImpact(shield, out var unknownShield); + if ( + status == PeriodicStatus.Regen + && unknownHealth > 0 + && ownership.UnknownOrigins + is PeriodicUnknownOrigin.None + or PeriodicUnknownOrigin.InitialState + ) + { + foreach (var (sourceId, amount) in ownership.SplitAmongKnownSources(unknownHealth)) + { + if (amount <= 0) + continue; + var existing = healthShares.GetValueOrDefault(sourceId); + healthShares[sourceId] = new ImpactShare(existing.Amount + amount, true); + unknownHealth -= amount; + } + if (unknownHealth > 0 && regenFallbackSources.Count > 0) + { + foreach ( + var (sourceId, amount) in OwnershipLedger.SplitAmongSources( + unknownHealth, + regenFallbackSources, + sourceId => StatWeight(simulation, sourceId, PeriodicStatus.Regen) + ) + ) + { + if (amount <= 0) + continue; + var existing = healthShares.GetValueOrDefault(sourceId); + healthShares[sourceId] = new ImpactShare(existing.Amount + amount, true); + unknownHealth -= amount; + } + } + } + if (unknownHealth > 0 || unknownShield > 0) + { + diagnostics?.ObserveGap( + frameIndex, + combatant, + Kind(status), + unknownHealth, + unknownShield, + ownership.UnknownOrigins == PeriodicUnknownOrigin.None + ? PeriodicUnknownOrigin.EmptyLedger + : ownership.UnknownOrigins + ); + } + foreach (var sourceId in healthShares.Keys.Union(shieldShares.Keys, StringComparer.Ordinal)) + { + var healthShare = healthShares.GetValueOrDefault(sourceId); + var shieldShare = shieldShares.GetValueOrDefault(sourceId); + if (healthShare.Amount == 0 && shieldShare.Amount == 0) + continue; + + var key = new PeriodicImpactKey(sourceId, Kind(status)); + if (!impacts.TryGetValue(key, out var accumulator)) + { + accumulator = new ImpactAccumulator(); + impacts[key] = accumulator; + } + var approximate = healthShare.IsApproximate || shieldShare.IsApproximate; + diagnostics?.ObserveAllocation( + frameIndex, + combatant, + Kind(status), + sourceId, + healthShare.Amount, + shieldShare.Amount, + approximate + ? CombatImpactPeriodicProof.Proportional + : CombatImpactPeriodicProof.Exact + ); + accumulator.Add(healthShare.Amount, shieldShare.Amount, approximate); + } + } + + private static void ReconcileStatus( + CombatSimFrame frame, + CombatSimPlayerUpdate? update, + ECombatantId combatant, + PeriodicStatus status, + CombatantLedger ledger, + CombatSim simulation, + IReadOnlyDictionary entities + ) + { + var attribute = Attribute(status); + var opening = ledger.Read(attribute); + var ownership = ledger.Ownership[status]; + var ticked = status switch + { + PeriodicStatus.Burn => HasAdjustment(frame, combatant, EDamageType.Burn), + PeriodicStatus.Poison => HasAdjustment(frame, combatant, EDamageType.Poison), + PeriodicStatus.Regen => HasAdjustment(frame, combatant, EDamageType.Regen), + _ => false, + }; + var nativeDecay = + status == PeriodicStatus.Burn && ticked && opening > 0 + ? Math.Max(1, (int)Math.Floor(opening * 0.03)) + : 0; + if (nativeDecay > 0) + ownership.Remove(nativeDecay); + + var baseline = opening - nativeDecay; + var closing = + update != null && update.Attributes.TryGetValue(attribute, out var statusUpdate) + ? statusUpdate.CurrentValue + : baseline; + var netExternalChange = closing - baseline; + var sources = ResolveApplySources(frame, combatant, status, entities); + if (netExternalChange > 0) + { + ownership.Add( + netExternalChange, + sources, + sourceId => StatWeight(simulation, sourceId, status) + ); + } + else if (netExternalChange < 0) + { + ownership.Remove(-netExternalChange); + if (sources.Count > 0) + ownership.Contaminate(); + } + else if (sources.Count > 0) + { + ownership.Contaminate(); + } + + ownership.ReconcileTotal(closing); + ledger.Attributes[attribute] = closing; + } + + private static IReadOnlyList ResolveApplySources( + CombatSimFrame frame, + ECombatantId combatant, + PeriodicStatus status, + IReadOnlyDictionary entities + ) + { + var expectedAction = status switch + { + PeriodicStatus.Burn => EActionCommandType.PlayerBurnApply, + PeriodicStatus.Poison => EActionCommandType.PlayerPoisonApply, + PeriodicStatus.Regen => EActionCommandType.PlayerRegenApply, + _ => EActionCommandType.None, + }; + var sources = new List(); + var hasUnresolved = false; + foreach (var effect in frame.Events.OfType()) + { + if ( + effect.ActionType != expectedAction + || effect.Target is not EffectTargetPlayer player + || player.Target != combatant + ) + { + continue; + } + + var sourceId = ResolveActivitySource(effect, entities); + if (sourceId == null) + hasUnresolved = true; + else + sources.Add(sourceId); + } + + var resolved = sources + .Distinct(StringComparer.Ordinal) + .OrderBy(source => source, StringComparer.Ordinal) + .ToList(); + if (hasUnresolved) + resolved.Add(OwnershipLedger.UnknownSource); + return resolved; + } + + private static string? ResolveActivitySource( + CombatSimEventEffectExecuted effect, + IReadOnlyDictionary entities + ) + { + foreach (var candidate in new[] { effect.Source?.Value, effect.TriggerSource?.Value }) + { + if ( + candidate != null + && entities.TryGetValue(candidate, out var entity) + && entity.TypeLabel is "Item" or "Skill" + ) + { + return candidate; + } + } + return null; + } + + private static int StatWeight(CombatSim simulation, string sourceId, PeriodicStatus status) + { + if (sourceId == OwnershipLedger.UnknownSource) + return 1; + if (!simulation.CardStats.TryGetValue(sourceId, out var stats)) + return 1; + var stat = status switch + { + PeriodicStatus.Burn => ECardStats.BurnAdded, + PeriodicStatus.Poison => ECardStats.PoisonAdded, + PeriodicStatus.Regen => ECardStats.RegenAdded, + _ => ECardStats.UseCount, + }; + return Math.Max(1, stats.GetValueOrDefault(stat)); + } + + private static IReadOnlyDictionary< + ECombatantId, + IReadOnlyList + > BuildRegenFallbackSources( + CombatSim simulation, + IReadOnlyDictionary entities + ) + { + var candidates = new Dictionary> + { + [ECombatantId.Player] = new(StringComparer.Ordinal), + [ECombatantId.Opponent] = new(StringComparer.Ordinal), + }; + + foreach (var frame in simulation.Frames) + { + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + foreach ( + var sourceId in ResolveApplySources( + frame, + combatant, + PeriodicStatus.Regen, + entities + ) + ) + { + if (sourceId != OwnershipLedger.UnknownSource) + candidates[combatant].Add(sourceId); + } + } + } + + foreach (var (sourceId, stats) in simulation.CardStats) + { + if ( + stats.GetValueOrDefault(ECardStats.RegenAdded) <= 0 + || !entities.TryGetValue(sourceId, out var entity) + || entity.CombatantId is not { } combatant + || entity.TypeLabel is not ("Item" or "Skill") + ) + { + continue; + } + candidates[combatant].Add(sourceId); + } + + return candidates.ToDictionary( + pair => pair.Key, + pair => + (IReadOnlyList) + pair.Value.OrderBy(source => source, StringComparer.Ordinal).ToArray() + ); + } + + private static bool HasAdjustment( + CombatSimFrame frame, + ECombatantId combatant, + EDamageType type + ) + { + var update = combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + return update?.HealthAdjustments.Any(adjustment => adjustment.DamageType == type) == true; + } + + private static void ApplyAttributeUpdates( + CombatSimPlayerUpdate? update, + IDictionary attributes + ) + { + if (update == null) + return; + foreach (var (attribute, transition) in update.Attributes) + attributes[attribute] = transition.CurrentValue; + } + + private static long Loss( + CombatSimPlayerUpdate update, + EDamageType type, + EPlayerHealthChangeType pool + ) => + -update + .HealthAdjustments.Where(adjustment => + adjustment.DamageType == type + && adjustment.AttributeChanged == pool + && adjustment.Amount < 0 + ) + .Sum(adjustment => (long)adjustment.Amount); + + private static long Gain( + CombatSimPlayerUpdate update, + EDamageType type, + EPlayerHealthChangeType pool + ) => + update + .HealthAdjustments.Where(adjustment => + adjustment.DamageType == type + && adjustment.AttributeChanged == pool + && adjustment.Amount > 0 + ) + .Sum(adjustment => (long)adjustment.Amount); + + private static EPlayerAttributeType Attribute(PeriodicStatus status) => + status switch + { + PeriodicStatus.Burn => EPlayerAttributeType.Burn, + PeriodicStatus.Poison => EPlayerAttributeType.Poison, + PeriodicStatus.Regen => EPlayerAttributeType.HealthRegen, + _ => throw new ArgumentOutOfRangeException(nameof(status)), + }; + + private static CombatImpactPeriodicKind Kind(PeriodicStatus status) => + status switch + { + PeriodicStatus.Burn => CombatImpactPeriodicKind.Burn, + PeriodicStatus.Poison => CombatImpactPeriodicKind.Poison, + PeriodicStatus.Regen => CombatImpactPeriodicKind.Regen, + _ => throw new ArgumentOutOfRangeException(nameof(status)), + }; + + private static int SaturatingInt(long value) => + value > int.MaxValue ? int.MaxValue + : value < int.MinValue ? int.MinValue + : (int)value; + + private enum PeriodicStatus + { + Burn, + Poison, + Regen, + } + + private sealed class CombatantLedger + { + internal CombatantLedger(Dictionary attributes) + { + Attributes = attributes; + Ownership = new Dictionary + { + [PeriodicStatus.Burn] = new(), + [PeriodicStatus.Poison] = new(), + [PeriodicStatus.Regen] = new(), + }; + } + + internal Dictionary Attributes { get; } + + internal Dictionary Ownership { get; } + + internal int Read(EPlayerAttributeType attribute) => + Attributes.GetValueOrDefault(attribute); + } + + private sealed class OwnershipLedger + { + internal const string UnknownSource = "\0unknown-periodic-source"; + + private readonly Dictionary _balances = new(StringComparer.Ordinal); + private PeriodicUnknownOrigin _unknownOrigins; + + internal PeriodicUnknownOrigin UnknownOrigins => _unknownOrigins; + + internal void AddUnknown(long amount, PeriodicUnknownOrigin origin) + { + if (amount <= 0) + return; + _unknownOrigins |= origin; + AddBalance(UnknownSource, amount, true); + } + + internal void Add(long amount, IReadOnlyList sources, Func weight) + { + if (amount <= 0) + return; + if (sources.Count == 0) + { + AddUnknown(amount, PeriodicUnknownOrigin.MissingApplySource); + return; + } + if (sources.Count == 1) + { + if (sources[0] == UnknownSource) + AddUnknown(amount, PeriodicUnknownOrigin.MissingApplySource); + else + AddBalance(sources[0], amount, false); + return; + } + + foreach (var (sourceId, share) in Split(amount, sources, source => weight(source))) + { + if (sourceId == UnknownSource) + AddUnknown(share, PeriodicUnknownOrigin.MissingApplySource); + else + AddBalance(sourceId, share, true); + } + } + + internal void Remove(long amount) + { + var total = _balances.Values.Sum(balance => balance.Amount); + if (amount <= 0 || total <= 0) + return; + amount = Math.Min(amount, total); + var keys = _balances.Keys.OrderBy(key => key, StringComparer.Ordinal).ToArray(); + var removals = Split(amount, keys, key => _balances[key].Amount); + var ambiguous = keys.Length > 1; + foreach (var (key, removal) in removals) + { + var balance = _balances[key]; + var remaining = balance.Amount - removal; + if (remaining <= 0) + _balances.Remove(key); + else + _balances[key] = balance with + { + Amount = remaining, + IsApproximate = balance.IsApproximate || ambiguous, + }; + } + if (!_balances.ContainsKey(UnknownSource)) + _unknownOrigins = PeriodicUnknownOrigin.None; + } + + internal void ReconcileTotal(long expected) + { + expected = Math.Max(0, expected); + var current = _balances.Values.Sum(balance => balance.Amount); + if (current < expected) + { + AddUnknown(expected - current, PeriodicUnknownOrigin.ReconciledStatusDeficit); + } + else if (current > expected) + Remove(current - expected); + } + + internal void Contaminate() + { + var total = _balances.Values.Sum(balance => balance.Amount); + _balances.Clear(); + _unknownOrigins = PeriodicUnknownOrigin.None; + AddUnknown(total, PeriodicUnknownOrigin.ContaminatedEpoch); + } + + internal IReadOnlyDictionary SplitImpact( + long amount, + out long unknownAmount + ) + { + var result = new Dictionary(StringComparer.Ordinal); + unknownAmount = 0; + if (amount <= 0) + return result; + if (_balances.Count == 0) + { + unknownAmount = amount; + return result; + } + + var keys = _balances.Keys.OrderBy(key => key, StringComparer.Ordinal).ToArray(); + var mixed = keys.Length > 1 || keys.Contains(UnknownSource, StringComparer.Ordinal); + foreach (var (key, share) in Split(amount, keys, key => _balances[key].Amount)) + { + if (key == UnknownSource) + { + unknownAmount = share; + continue; + } + if (share == 0) + continue; + result[key] = new ImpactShare(share, mixed || _balances[key].IsApproximate); + } + return result; + } + + internal IReadOnlyDictionary SplitAmongKnownSources(long amount) + { + var keys = _balances + .Keys.Where(key => key != UnknownSource) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + return Split(amount, keys, key => _balances[key].Amount); + } + + internal static IReadOnlyDictionary SplitAmongSources( + long amount, + IReadOnlyList sources, + Func weight + ) => Split(amount, sources, weight); + + private void AddBalance(string sourceId, long amount, bool approximate) + { + if (amount <= 0) + return; + if (_balances.TryGetValue(sourceId, out var existing)) + { + _balances[sourceId] = existing with + { + Amount = existing.Amount + amount, + IsApproximate = existing.IsApproximate || approximate, + }; + } + else + { + _balances[sourceId] = new OwnerBalance(amount, approximate); + } + } + + private static IReadOnlyDictionary Split( + long amount, + IReadOnlyList keys, + Func weight + ) + { + var result = keys.ToDictionary(key => key, _ => 0L, StringComparer.Ordinal); + if (amount <= 0 || keys.Count == 0) + return result; + + var weights = keys.ToDictionary( + key => key, + key => Math.Max(0, weight(key)), + StringComparer.Ordinal + ); + var totalWeight = weights.Values.Sum(); + if (totalWeight == 0) + { + foreach (var key in keys) + weights[key] = 1; + totalWeight = keys.Count; + } + + long assigned = 0; + var remainders = new List<(string Key, long Remainder)>(); + foreach (var key in keys) + { + var product = amount * weights[key]; + var share = product / totalWeight; + result[key] = share; + assigned += share; + remainders.Add((key, product % totalWeight)); + } + + foreach ( + var remainder in remainders + .OrderByDescending(item => item.Remainder) + .ThenBy(item => item.Key, StringComparer.Ordinal) + .Take((int)(amount - assigned)) + ) + { + result[remainder.Key]++; + } + return result; + } + + private sealed record OwnerBalance(long Amount, bool IsApproximate); + } + + private sealed class ImpactAccumulator + { + private long _health; + private long _shield; + private bool _approximate; + + internal void Add(long health, long shield, bool approximate) + { + _health += health; + _shield += shield; + _approximate |= approximate; + } + + internal CombatImpactPeriodicImpact Build() => + new( + SaturatingInt(_health), + SaturatingInt(_shield), + _approximate + ? CombatImpactPeriodicProof.Proportional + : CombatImpactPeriodicProof.Exact, + ModelVersion + ); + } + + private readonly record struct PoolResult( + bool HealthReconciled, + long BurnHealthDamage, + long BurnShieldConsumed, + long PoisonHealthDamage, + long RegenAttempted, + long RegenRealized + ) + { + internal static readonly PoolResult Empty = new(true, 0, 0, 0, 0, 0); + } + + private readonly record struct ImpactShare(long Amount, bool IsApproximate); +} + +[Flags] +internal enum PeriodicUnknownOrigin +{ + None = 0, + InitialState = 1 << 0, + MissingApplySource = 1 << 1, + ReconciledStatusDeficit = 1 << 2, + ContaminatedEpoch = 1 << 3, + EmptyLedger = 1 << 4, + HealthLedgerUnreconciled = 1 << 5, + StatusStateAbsent = 1 << 6, +} + +internal readonly record struct PeriodicAttributionGap( + int FrameIndex, + ECombatantId Combatant, + CombatImpactPeriodicKind Kind, + long HealthAmount, + long ShieldAmount, + PeriodicUnknownOrigin Origin +); + +internal readonly record struct PeriodicAttributionAllocation( + int FrameIndex, + ECombatantId Combatant, + CombatImpactPeriodicKind Kind, + string SourceId, + long HealthAmount, + long ShieldAmount, + CombatImpactPeriodicProof Proof +); + +internal sealed class PeriodicEffectAttributionDiagnostics +{ + private readonly List _gaps = []; + private readonly List _allocations = []; + + internal IReadOnlyList Gaps => _gaps; + internal IReadOnlyList Allocations => _allocations; + + internal void ObserveGap( + int frameIndex, + ECombatantId combatant, + CombatImpactPeriodicKind kind, + long healthAmount, + long shieldAmount, + PeriodicUnknownOrigin origin + ) => _gaps.Add(new(frameIndex, combatant, kind, healthAmount, shieldAmount, origin)); + + internal void ObserveAllocation( + int frameIndex, + ECombatantId combatant, + CombatImpactPeriodicKind kind, + string sourceId, + long healthAmount, + long shieldAmount, + CombatImpactPeriodicProof proof + ) => + _allocations.Add( + new(frameIndex, combatant, kind, sourceId, healthAmount, shieldAmount, proof) + ); +} diff --git a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs index fa58da80..d82eadec 100644 --- a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs +++ b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs @@ -40,6 +40,9 @@ internal sealed class NativePostCombatImpactTooltipView : IPostCombatImpactToolt private const float DisclosureFontScale = 0.62f; private const float GroupLabelFontScale = 1f; private const float GroupMetricFontScale = 1f; + private const float GroupSecondaryMetricFontScale = 0.62f; + private const float GroupStackedMetricMinFontScale = 0.68f; + private const float GroupStackedMetricOpticalOffset = 2f; private const float TargetNameFontScale = 0.875f; private const float TargetMetricFontScale = 0.875f; private const float MetricColumnMinWidth = 112f; @@ -698,6 +701,12 @@ int generation : group.ObservedValue, group.HasMixedValueDirections, CombatImpactMetricFormatter.Group(group, IsChinese(), CriticalMarker()), + CombatImpactMetricFormatter.PeriodicImpact( + group, + IsChinese(), + DamageMarker(), + ShieldMarker() + ), preferredHeight: hasTriggerSummary ? 44f : 48f ); if (hasTriggerSummary) @@ -782,6 +791,7 @@ private void BuildGroupHeader( int? changeValue, bool hasMixedValueDirections, string metricText, + string secondaryMetricText = "", float preferredHeight = 48f ) { @@ -816,15 +826,47 @@ private void BuildGroupHeader( // Group titles are controlled product labels and must stay literal even when the row is // constrained; unlike generic cloned text, they never opt into ellipsis. labelText.overflowMode = TextOverflowModes.Overflow; - var metric = CloneText( + if (string.IsNullOrWhiteSpace(secondaryMetricText)) + { + var metric = CloneText( + textTemplate, + header, + metricText, + GroupMetricFontScale, + minWidth: MetricColumnPreferredWidth + ); + _metricColumns.Add(metric.GetComponent()); + metric.alignment = TextAlignmentOptions.MidlineRight; + return; + } + + // Render the authoritative total and its ruby-like realized metric as one TMP block. Its + // preferred width can consume the header's unused space, while auto-sizing fits both lines + // together inside the fixed row height. Overflow remains literal; this Tooltip never + // replaces constrained text with ellipsis. + var secondaryColor = ColorUtility.ToHtmlStringRGB(DisclosureColor); + var stackedMetric = CloneText( textTemplate, header, - metricText, + $"{metricText}\n" + + $"{secondaryMetricText}", GroupMetricFontScale, + flexibleWidth: 1f, minWidth: MetricColumnPreferredWidth ); - _metricColumns.Add(metric.GetComponent()); - metric.alignment = TextAlignmentOptions.MidlineRight; + _metricColumns.Add(stackedMetric.GetComponent()); + stackedMetric.alignment = TextAlignmentOptions.MidlineRight; + stackedMetric.margin = new Vector4( + 0f, + GroupStackedMetricOpticalOffset, + 0f, + -GroupStackedMetricOpticalOffset + ); + stackedMetric.enableAutoSizing = true; + stackedMetric.fontSizeMin = Mathf.Max( + 1f, + stackedMetric.fontSizeMax * GroupStackedMetricMinFontScale + ); } private static void BuildTriggerSummary( @@ -1231,7 +1273,7 @@ private static TMP_Text CloneText( text.alpha = 1f; text.margin = Vector4.zero; text.textWrappingMode = TextWrappingModes.NoWrap; - text.overflowMode = TextOverflowModes.Ellipsis; + text.overflowMode = TextOverflowModes.Overflow; text.raycastTarget = false; text.richText = content.Contains(" KeywordMarker("CritChance"); + + private static string? DamageMarker() => KeywordMarker("DamageAmount"); + + private static string? ShieldMarker() => KeywordMarker("ShieldApplyAmount"); + + private static string? KeywordMarker(string key) { var marker = Data.TooltipTypography?.GetKeywordStringWithIconNoScale( - "CritChance", + key, string.Empty, useNumberFont: false ) ?? string.Empty; diff --git a/tests/PeriodicEffectAttribution.Corpus/ContractlessRunArtifact.cs b/tests/PeriodicEffectAttribution.Corpus/ContractlessRunArtifact.cs new file mode 100644 index 00000000..4e614a6e --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/ContractlessRunArtifact.cs @@ -0,0 +1,20 @@ +#nullable enable + +internal sealed class ContractlessRunArtifact +{ + public List Battles { get; set; } = []; +} + +internal sealed class ContractlessRunArtifactBattle +{ + public string BattleId { get; set; } = string.Empty; + + public ContractlessReplayPayload? ReplayPayload { get; set; } +} + +internal sealed class ContractlessReplayPayload +{ + public byte[] SpawnMessageBytes { get; set; } = []; + + public byte[] CombatMessageBytes { get; set; } = []; +} diff --git a/tests/PeriodicEffectAttribution.Corpus/CorpusInventory.cs b/tests/PeriodicEffectAttribution.Corpus/CorpusInventory.cs new file mode 100644 index 00000000..3f7ecbc1 --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/CorpusInventory.cs @@ -0,0 +1,2397 @@ +#nullable enable +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Domain.Effect; +using BazaarGameShared.Infra.Messages.CombatSimEvents; +using BazaarGameShared.Infra.Messages.GameSimEvents; +using BazaarGameShared.Infra.Messages.Shared; +using BazaarPlusPlus.Game.PostCombatImpact.Data; + +internal sealed record ReplayObservationInput(string BattleId, GameSim Spawn, CombatSim Combat); + +internal static class CorpusInventory +{ + private static readonly EPlayerAttributeType[] TrackedAttributes = + [ + EPlayerAttributeType.Burn, + EPlayerAttributeType.Poison, + EPlayerAttributeType.HealthRegen, + EPlayerAttributeType.Health, + EPlayerAttributeType.HealthMax, + EPlayerAttributeType.Shield, + EPlayerAttributeType.FlatDamageReduction, + EPlayerAttributeType.PercentDamageReduction, + ]; + + internal static CorpusInventoryReport Build( + IReadOnlyList replays, + IReadOnlyList invalidPayloads + ) + { + var adjustments = new SortedDictionary(StringComparer.Ordinal); + var transitions = new SortedDictionary(StringComparer.Ordinal); + var actions = new SortedDictionary(StringComparer.Ordinal); + var openingActions = new SortedDictionary(StringComparer.Ordinal); + var openingActionSources = new List(); + var periodicFrames = new List(); + var battleSummaries = new List(); + var missingStatSourceIdentities = new List(); + var unresolvedActionSources = new List(); + var multiSourceBattles = new SortedDictionary(StringComparer.Ordinal) + { + ["Burn"] = 0, + ["Poison"] = 0, + ["Regen"] = 0, + }; + var scenarios = new ScenarioCoverage(); + var attribution = new AttributionCoverage(); + var terminalImpact = new TerminalImpactCoverage(); + var resolvedActions = 0; + var unresolvedActions = 0; + var totalFrames = 0; + var periodicAdjustmentCount = 0; + + foreach (var replay in replays.OrderBy(replay => replay.BattleId, StringComparer.Ordinal)) + { + totalFrames += replay.Combat.Frames.Count; + var identities = BuildIdentityMap(replay); + ReadOpeningActions(replay, identities, openingActions, openingActionSources); + var attributionDiagnostics = new PeriodicEffectAttributionDiagnostics(); + attribution.Observe( + PeriodicEffectAttribution.Project( + replay.Combat, + BuildAttributionEntities(replay, identities), + attributionDiagnostics + ), + attributionDiagnostics, + replay.Combat, + identities, + replay.Spawn + ); + terminalImpact.Observe(replay.Combat, replay.Spawn); + var statSources = CountStatSources(replay.Combat); + var statAmounts = SumStatAmounts(replay.Combat); + foreach (var (status, count) in statSources) + { + if (count > 1) + multiSourceBattles[status]++; + } + + var battleMissingIdentities = FindMissingStatSourceIdentities(replay, identities); + missingStatSourceIdentities.AddRange(battleMissingIdentities); + var states = CreateStateMaps(replay); + var battlePeriodicFrames = 0; + + for (var frameIndex = 0; frameIndex < replay.Combat.Frames.Count; frameIndex++) + { + var frame = replay.Combat.Frames[frameIndex]; + AddTransformIdentities(frame, identities); + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + var update = + combatant == ECombatantId.Player + ? frame.PlayerUpdates + : frame.OpponentUpdates; + var before = Snapshot(states[combatant]); + AddTransitions(update, transitions); + ApplyUpdates(states[combatant], update); + var after = Snapshot(states[combatant]); + var frameAdjustments = ReadAdjustments(update, adjustments); + var framePeriodicAdjustmentCount = frameAdjustments.Count(adjustment => + IsPeriodic(adjustment.DamageType) + ); + periodicAdjustmentCount += framePeriodicAdjustmentCount; + var frameActions = ReadActions( + replay.BattleId, + frameIndex, + combatant, + frame, + update, + identities, + actions, + unresolvedActionSources, + ref resolvedActions, + ref unresolvedActions + ); + + if ( + framePeriodicAdjustmentCount == 0 + && frameActions.Count == 0 + && !HasPeriodicTransition(update) + ) + { + continue; + } + + var observation = new PeriodicFrameObservation( + replay.BattleId, + frameIndex, + combatant.ToString(), + before, + after, + frameAdjustments, + frameActions, + ReadContextActions(frame, combatant, identities), + frame + .Events.OfType() + .Any(death => death.CombatantId == combatant) + ); + periodicFrames.Add(observation); + battlePeriodicFrames++; + scenarios.Observe(observation); + } + } + + battleSummaries.Add( + new BattleInventorySummary( + replay.BattleId, + replay.Combat.Frames.Count, + battlePeriodicFrames, + statSources, + statAmounts, + battleMissingIdentities.Count + ) + ); + } + + return new CorpusInventoryReport( + SchemaVersion: 4, + Battles: replays.Count, + Frames: totalFrames, + InvalidPayloads: invalidPayloads.Order(StringComparer.Ordinal).ToArray(), + PeriodicAdjustmentCount: periodicAdjustmentCount, + Adjustments: adjustments, + StatusTransitions: transitions, + Actions: actions, + OpeningActions: openingActions, + OpeningActionSources: openingActionSources + .OrderBy(action => action.BattleId, StringComparer.Ordinal) + .ThenBy(action => action.Action, StringComparer.Ordinal) + .ThenBy(action => action.SourceId, StringComparer.Ordinal) + .ToArray(), + Sources: new SourceCoverage( + resolvedActions, + unresolvedActions, + missingStatSourceIdentities.Order(StringComparer.Ordinal).ToArray(), + unresolvedActionSources.Order(StringComparer.Ordinal).ToArray(), + multiSourceBattles + ), + Scenarios: scenarios, + Rules: RuleCandidateCoverage.Build(periodicFrames), + TerminalImpact: terminalImpact, + Attribution: attribution, + BattlesSummary: battleSummaries, + PeriodicFrames: periodicFrames + ); + } + + private static Dictionary BuildIdentityMap(ReplayObservationInput replay) + { + var identities = new Dictionary(StringComparer.Ordinal); + foreach (var (instanceId, card) in replay.Spawn.Cards) + { + identities[instanceId] = new CardIdentity( + TemplateId: null, + InferCardType(instanceId), + card.Placement?.Owner + ); + } + foreach (var instanceId in replay.Combat.CardStats.Keys) + { + var inferredType = InferCardType(instanceId); + if (inferredType.HasValue && !identities.ContainsKey(instanceId)) + identities[instanceId] = new CardIdentity(null, inferredType, null); + } + foreach (var spawned in replay.Spawn.Events.OfType()) + { + identities[spawned.InstanceId] = new CardIdentity( + spawned.TemplateId, + spawned.Type, + spawned.CombatantId + ); + } + + foreach (var frame in replay.Combat.Frames) + AddTransformIdentities(frame, identities); + return identities; + } + + private static IReadOnlyDictionary BuildAttributionEntities( + ReplayObservationInput replay, + IReadOnlyDictionary identities + ) => + identities + .Where(pair => pair.Value.Type is ECardType.Item or ECardType.Skill) + .ToDictionary( + pair => pair.Key, + pair => new CombatImpactEntity( + pair.Key, + pair.Value.TemplateId ?? pair.Key, + pair.Value.Type!.Value.ToString(), + null, + 0, + Attributes: replay.Spawn.Cards.TryGetValue(pair.Key, out var card) + ? card.Attributes.ToDictionary( + attribute => attribute.Key, + attribute => attribute.Value.Value + ) + : null, + CombatantId: pair.Value.Combatant + ), + StringComparer.Ordinal + ); + + private static void ReadOpeningActions( + ReplayObservationInput replay, + IReadOnlyDictionary identities, + IDictionary counts, + ICollection observations + ) + { + foreach (var effect in replay.Spawn.Events.OfType()) + { + if ( + effect.ActionType + is not ( + EActionCommandType.PlayerBurnApply + or EActionCommandType.PlayerPoisonApply + or EActionCommandType.PlayerRegenApply + ) + ) + { + continue; + } + + var action = effect.ActionType.ToString(); + counts.TryGetValue(action, out var count); + counts[action] = count + 1; + observations.Add( + new OpeningActionObservation( + replay.BattleId, + action, + ResolveOpeningSource(effect, identities), + effect.Source?.Value, + effect.TriggerSource?.Value, + effect.Target is EffectTargetPlayer player ? player.Target.ToString() : null + ) + ); + } + } + + private static string? ResolveOpeningSource( + GameSimEventEffectExecuted effect, + IReadOnlyDictionary identities + ) + { + foreach (var candidate in new[] { effect.Source?.Value, effect.TriggerSource?.Value }) + { + if ( + candidate != null + && identities.TryGetValue(candidate, out var identity) + && identity.Type is ECardType.Item or ECardType.Skill + ) + { + return candidate; + } + } + return null; + } + + private static ECardType? InferCardType(string instanceId) => + instanceId.StartsWith("itm_", StringComparison.Ordinal) ? ECardType.Item + : instanceId.StartsWith("skl_", StringComparison.Ordinal) ? ECardType.Skill + : null; + + private static void AddTransformIdentities( + CombatSimFrame frame, + IDictionary identities + ) + { + foreach (var transformed in frame.Events.OfType()) + { + foreach (var card in transformed.TransformedCards) + { + identities[card.InstanceId] = new CardIdentity( + card.TemplateId, + card.Type, + card.CombatantId + ); + } + } + + foreach (var reverted in frame.Events.OfType()) + { + var card = reverted.OriginalCard; + identities[card.InstanceId] = new CardIdentity( + card.TemplateId, + card.Type, + card.CombatantId + ); + } + } + + private static SortedDictionary CountStatSources(CombatSim combat) + { + var result = new SortedDictionary(StringComparer.Ordinal) + { + ["Burn"] = 0, + ["Poison"] = 0, + ["Regen"] = 0, + }; + foreach (var stats in combat.CardStats.Values) + { + if (stats.GetValueOrDefault(ECardStats.BurnAdded) != 0) + result["Burn"]++; + if (stats.GetValueOrDefault(ECardStats.PoisonAdded) != 0) + result["Poison"]++; + if (stats.GetValueOrDefault(ECardStats.RegenAdded) != 0) + result["Regen"]++; + } + return result; + } + + private static SortedDictionary SumStatAmounts(CombatSim combat) + { + var result = new SortedDictionary(StringComparer.Ordinal) + { + ["Burn"] = 0, + ["Poison"] = 0, + ["Regen"] = 0, + }; + foreach (var stats in combat.CardStats.Values) + { + result["Burn"] += stats.GetValueOrDefault(ECardStats.BurnAdded); + result["Poison"] += stats.GetValueOrDefault(ECardStats.PoisonAdded); + result["Regen"] += stats.GetValueOrDefault(ECardStats.RegenAdded); + } + return result; + } + + private static List FindMissingStatSourceIdentities( + ReplayObservationInput replay, + IReadOnlyDictionary identities + ) + { + var missing = new List(); + foreach ( + var (sourceId, stats) in replay.Combat.CardStats.OrderBy( + pair => pair.Key, + StringComparer.Ordinal + ) + ) + { + foreach ( + var (stat, label) in new[] + { + (ECardStats.BurnAdded, "Burn"), + (ECardStats.PoisonAdded, "Poison"), + (ECardStats.RegenAdded, "Regen"), + } + ) + { + if (stats.GetValueOrDefault(stat) != 0 && !identities.ContainsKey(sourceId)) + missing.Add($"{replay.BattleId}/{sourceId}/{label}"); + } + } + return missing; + } + + private static Dictionary> CreateStateMaps( + ReplayObservationInput replay + ) + { + var states = new Dictionary> + { + [ECombatantId.Player] = new Dictionary( + replay.Spawn.Player.Attributes + ), + [ECombatantId.Opponent] = new Dictionary( + replay.Spawn.Opponent.Attributes + ), + }; + + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + var state = states[combatant]; + foreach (var frame in replay.Combat.Frames) + { + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + if (update == null) + continue; + foreach (var (attribute, transition) in update.Attributes) + state.TryAdd(attribute, transition.PreviousValue); + } + + state.TryAdd(EPlayerAttributeType.Burn, 0); + state.TryAdd(EPlayerAttributeType.Poison, 0); + state.TryAdd(EPlayerAttributeType.HealthRegen, 0); + state.TryAdd(EPlayerAttributeType.Shield, 0); + if ( + !state.ContainsKey(EPlayerAttributeType.HealthMax) + && state.TryGetValue(EPlayerAttributeType.Health, out var health) + ) + { + state[EPlayerAttributeType.HealthMax] = health; + } + } + + return states; + } + + private static AttributeSnapshot Snapshot( + IReadOnlyDictionary state + ) => + new( + Read(state, EPlayerAttributeType.Burn), + Read(state, EPlayerAttributeType.Poison), + Read(state, EPlayerAttributeType.HealthRegen), + Read(state, EPlayerAttributeType.Health), + Read(state, EPlayerAttributeType.HealthMax), + Read(state, EPlayerAttributeType.Shield), + Read(state, EPlayerAttributeType.FlatDamageReduction), + Read(state, EPlayerAttributeType.PercentDamageReduction) + ); + + private static int? Read( + IReadOnlyDictionary state, + EPlayerAttributeType attribute + ) => state.TryGetValue(attribute, out var value) ? value : null; + + private static void ApplyUpdates( + IDictionary state, + CombatSimPlayerUpdate? update + ) + { + if (update == null) + return; + foreach (var (attribute, transition) in update.Attributes) + state[attribute] = transition.CurrentValue; + } + + private static void AddTransitions( + CombatSimPlayerUpdate? update, + IDictionary transitions + ) + { + if (update == null) + return; + foreach (var attribute in TrackedAttributes.Take(3)) + { + if (!update.Attributes.TryGetValue(attribute, out var transition)) + continue; + var direction = + transition.Delta > 0 ? "Gain" + : transition.Delta < 0 ? "Loss" + : "Zero"; + var key = $"{attribute}/{direction}"; + if (!transitions.TryGetValue(key, out var aggregate)) + { + aggregate = new TransitionAggregate(); + transitions[key] = aggregate; + } + aggregate.Count++; + aggregate.NetDelta += transition.Delta; + aggregate.AbsoluteDelta += Math.Abs((long)transition.Delta); + } + } + + private static List ReadAdjustments( + CombatSimPlayerUpdate? update, + IDictionary aggregates + ) + { + var result = new List(); + if (update == null) + return result; + foreach (var adjustment in update.HealthAdjustments) + { + var sign = + adjustment.Amount > 0 ? "Gain" + : adjustment.Amount < 0 ? "Loss" + : "Zero"; + var key = $"{adjustment.DamageType}/{adjustment.AttributeChanged}/{sign}"; + if (!aggregates.TryGetValue(key, out var aggregate)) + { + aggregate = new AdjustmentAggregate(); + aggregates[key] = aggregate; + } + aggregate.Count++; + aggregate.NetAmount += adjustment.Amount; + aggregate.AbsoluteAmount += Math.Abs((long)adjustment.Amount); + if (adjustment.IsCrit) + aggregate.CriticalCount++; + if (adjustment.IsDamageReduced) + aggregate.ReducedCount++; + result.Add( + new AdjustmentObservation( + adjustment.DamageType.ToString(), + adjustment.AttributeChanged.ToString(), + adjustment.Amount, + adjustment.IsCrit, + adjustment.IsDamageReduced + ) + ); + } + return result; + } + + private static List ReadActions( + string battleId, + int frameIndex, + ECombatantId combatant, + CombatSimFrame frame, + CombatSimPlayerUpdate? update, + IReadOnlyDictionary identities, + IDictionary actionCounts, + ICollection unresolvedActionSources, + ref int resolvedActions, + ref int unresolvedActions + ) + { + var result = new List(); + foreach (var effect in frame.Events.OfType()) + { + if (!Targets(effect, combatant) || !IsPeriodicAction(effect.ActionType, update)) + continue; + var action = effect.ActionType.ToString(); + actionCounts.TryGetValue(action, out var actionCount); + actionCounts[action] = actionCount + 1; + var sourceId = ResolveSource(effect, identities); + if (sourceId == null) + { + unresolvedActions++; + unresolvedActionSources.Add($"{battleId}/{frameIndex}/{combatant}/{action}"); + } + else + { + resolvedActions++; + } + result.Add( + new ActionObservation( + action, + sourceId, + effect.Source?.Value, + effect.TriggerSource?.Value + ) + ); + } + return result; + } + + internal static string? ResolveSource( + CombatSimEventEffectExecuted effect, + IReadOnlyDictionary identities + ) + { + foreach (var candidate in new[] { effect.Source?.Value, effect.TriggerSource?.Value }) + { + if ( + candidate != null + && identities.TryGetValue(candidate, out var identity) + && identity.Type is ECardType.Item or ECardType.Skill + ) + { + return candidate; + } + } + return null; + } + + private static bool Targets(CombatSimEventEffectExecuted effect, ECombatantId combatant) => + effect.Target is EffectTargetPlayer player && player.Target == combatant; + + private static IReadOnlyList ReadContextActions( + CombatSimFrame frame, + ECombatantId combatant, + IReadOnlyDictionary identities + ) => + frame + .Events.OfType() + .Where(effect => Targets(effect, combatant)) + .Select(effect => new ActionObservation( + effect.ActionType.ToString(), + ResolveSource(effect, identities), + effect.Source?.Value, + effect.TriggerSource?.Value + )) + .ToArray(); + + private static bool IsPeriodicAction( + EActionCommandType action, + CombatSimPlayerUpdate? update + ) => + action + is EActionCommandType.PlayerBurnApply + or EActionCommandType.PlayerBurnRemove + or EActionCommandType.PlayerPoisonApply + or EActionCommandType.PlayerPoisonRemove + or EActionCommandType.PlayerRegenApply + or EActionCommandType.PlayerRegenRemove + || action == EActionCommandType.PlayerModifyAttribute && HasPeriodicTransition(update); + + private static bool HasPeriodicTransition(CombatSimPlayerUpdate? update) => + update != null + && TrackedAttributes + .Take(3) + .Any(attribute => + update.Attributes.TryGetValue(attribute, out var transition) + && transition.Delta != 0 + ); + + private static bool IsPeriodic(EDamageType type) => + type is EDamageType.Burn or EDamageType.Poison or EDamageType.Regen; + + private static bool IsPeriodic(string type) => type is "Burn" or "Poison" or "Regen"; +} + +internal sealed record CardIdentity(string? TemplateId, ECardType? Type, ECombatantId? Combatant); + +internal sealed record CorpusInventoryReport( + int SchemaVersion, + int Battles, + int Frames, + IReadOnlyList InvalidPayloads, + int PeriodicAdjustmentCount, + IReadOnlyDictionary Adjustments, + IReadOnlyDictionary StatusTransitions, + IReadOnlyDictionary Actions, + IReadOnlyDictionary OpeningActions, + IReadOnlyList OpeningActionSources, + SourceCoverage Sources, + ScenarioCoverage Scenarios, + RuleCandidateCoverage Rules, + TerminalImpactCoverage TerminalImpact, + AttributionCoverage Attribution, + IReadOnlyList BattlesSummary, + IReadOnlyList PeriodicFrames +); + +internal sealed class TerminalImpactCoverage +{ + public int DeathEvents { get; private set; } + public int CombatantsWithMultipleDeathEvents { get; private set; } + public int FramesAfterFirstDeathEvent { get; private set; } + public int PositiveHealthFramesAfterFirstDeathEvent { get; private set; } + public int PeriodicAdjustmentFramesAfterFirstDeathEvent { get; private set; } + public SortedDictionary PeriodicAdjustmentAmountAfterFirstDeathEvent { get; } = + new(StringComparer.Ordinal); + public SortedDictionary PeriodicHealthLossOnDeathEventFrames { get; } = + new(StringComparer.Ordinal); + public SortedDictionary< + string, + long + > CombatEffectivePeriodicHealthLossOnDeathEventFrames { get; } = new(StringComparer.Ordinal); + public SortedDictionary PeriodicHealthOverkillOnDeathEventFrames { get; } = + new(StringComparer.Ordinal); + public SortedDictionary< + string, + long + > ReversedOrderCombatEffectivePeriodicHealthLossOnDeathEventFrames { get; } = + new(StringComparer.Ordinal); + public long BurnShieldConsumedOnDeathEventFrames { get; private set; } + public long CombatEffectiveBurnShieldConsumedOnDeathEventFrames { get; private set; } + public long BurnShieldConsumedAfterLethalHealthOnDeathEventFrames { get; private set; } + public long ReversedOrderCombatEffectiveBurnShieldConsumedOnDeathEventFrames + { + get; + private set; + } + public long AtomicBurnGroupCombatEffectiveHealthLossOnDeathEventFrames { get; private set; } + public long AtomicBurnGroupCombatEffectiveShieldConsumedOnDeathEventFrames { get; private set; } + + internal void Observe(CombatSim combat, GameSim spawn) + { + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + var state = new Dictionary( + combatant == ECombatantId.Player + ? spawn.Player.Attributes + : spawn.Opponent.Attributes + ); + foreach (var frame in combat.Frames) + { + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + if (update == null) + continue; + foreach (var (attribute, transition) in update.Attributes) + state.TryAdd(attribute, transition.PreviousValue); + } + if ( + !state.ContainsKey(EPlayerAttributeType.HealthMax) + && state.TryGetValue(EPlayerAttributeType.Health, out var initialHealth) + ) + { + state[EPlayerAttributeType.HealthMax] = initialHealth; + } + + var deathEvents = 0; + for (var frameIndex = 0; frameIndex < combat.Frames.Count; frameIndex++) + { + var frame = combat.Frames[frameIndex]; + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + var hasDeathEvent = frame + .Events.OfType() + .Any(death => death.CombatantId == combatant); + + if (deathEvents > 0) + { + FramesAfterFirstDeathEvent++; + if ( + state.GetValueOrDefault(EPlayerAttributeType.Health) > 0 + || ( + update?.Attributes.TryGetValue( + EPlayerAttributeType.Health, + out var postDeathHealth + ) == true + && postDeathHealth.CurrentValue > 0 + ) + ) + { + PositiveHealthFramesAfterFirstDeathEvent++; + } + + var postDeathPeriodicAdjustments = + update + ?.HealthAdjustments.Where(adjustment => + adjustment.DamageType + is EDamageType.Burn + or EDamageType.Poison + or EDamageType.Regen + ) + .ToArray() + ?? []; + if (postDeathPeriodicAdjustments.Length > 0) + { + PeriodicAdjustmentFramesAfterFirstDeathEvent++; + foreach (var adjustment in postDeathPeriodicAdjustments) + { + var key = $"{adjustment.DamageType}/{adjustment.AttributeChanged}"; + PeriodicAdjustmentAmountAfterFirstDeathEvent[key] = + PeriodicAdjustmentAmountAfterFirstDeathEvent.GetValueOrDefault(key) + + Math.Abs((long)adjustment.Amount); + } + } + } + + if ( + update != null + && state.TryGetValue(EPlayerAttributeType.Health, out var openingHealth) + && state.TryGetValue(EPlayerAttributeType.HealthMax, out var openingMax) + ) + { + var closingMax = update.Attributes.TryGetValue( + EPlayerAttributeType.HealthMax, + out var maxTransition + ) + ? maxTransition.CurrentValue + : openingMax; + long health = openingHealth + (closingMax - openingMax); + var terminalReached = hasDeathEvent && health <= 0; + foreach (var adjustment in update.HealthAdjustments) + { + if (adjustment.AttributeChanged != EPlayerHealthChangeType.Health) + { + if ( + hasDeathEvent + && adjustment.DamageType == EDamageType.Burn + && adjustment.AttributeChanged == EPlayerHealthChangeType.Shield + && adjustment.Amount < 0 + ) + { + var consumed = -(long)adjustment.Amount; + BurnShieldConsumedOnDeathEventFrames += consumed; + if (!terminalReached) + CombatEffectiveBurnShieldConsumedOnDeathEventFrames += consumed; + else + BurnShieldConsumedAfterLethalHealthOnDeathEventFrames += + consumed; + } + continue; + } + if (adjustment.Amount <= 0) + { + if ( + hasDeathEvent + && adjustment.DamageType is EDamageType.Burn or EDamageType.Poison + ) + { + var key = adjustment.DamageType.ToString(); + var loss = -(long)adjustment.Amount; + var effective = terminalReached + ? 0L + : Math.Min(loss, Math.Max(0L, health)); + PeriodicHealthLossOnDeathEventFrames[key] = + PeriodicHealthLossOnDeathEventFrames.GetValueOrDefault(key) + + loss; + CombatEffectivePeriodicHealthLossOnDeathEventFrames[key] = + CombatEffectivePeriodicHealthLossOnDeathEventFrames.GetValueOrDefault( + key + ) + effective; + PeriodicHealthOverkillOnDeathEventFrames[key] = + PeriodicHealthOverkillOnDeathEventFrames.GetValueOrDefault(key) + + (loss - effective); + } + health += adjustment.Amount; + if (hasDeathEvent && health <= 0) + terminalReached = true; + continue; + } + + var accepted = Math.Min( + adjustment.Amount, + Math.Max(0L, closingMax - health) + ); + health += accepted; + } + + if (hasDeathEvent) + { + long reversedHealth = openingHealth + (closingMax - openingMax); + var reversedTerminalReached = reversedHealth <= 0; + foreach ( + var adjustment in update.HealthAdjustments.AsEnumerable().Reverse() + ) + { + if (adjustment.AttributeChanged != EPlayerHealthChangeType.Health) + { + if ( + adjustment.DamageType == EDamageType.Burn + && adjustment.AttributeChanged == EPlayerHealthChangeType.Shield + && adjustment.Amount < 0 + && !reversedTerminalReached + ) + { + ReversedOrderCombatEffectiveBurnShieldConsumedOnDeathEventFrames += + -(long)adjustment.Amount; + } + continue; + } + if (adjustment.Amount <= 0) + { + if (adjustment.DamageType is EDamageType.Burn or EDamageType.Poison) + { + var key = adjustment.DamageType.ToString(); + var loss = -(long)adjustment.Amount; + var effective = reversedTerminalReached + ? 0L + : Math.Min(loss, Math.Max(0L, reversedHealth)); + ReversedOrderCombatEffectivePeriodicHealthLossOnDeathEventFrames[ + key + ] = + ReversedOrderCombatEffectivePeriodicHealthLossOnDeathEventFrames.GetValueOrDefault( + key + ) + effective; + } + reversedHealth += adjustment.Amount; + if (reversedHealth <= 0) + reversedTerminalReached = true; + continue; + } + + var reversedAccepted = Math.Min( + adjustment.Amount, + Math.Max(0L, closingMax - reversedHealth) + ); + reversedHealth += reversedAccepted; + } + + long groupedHealth = openingHealth + (closingMax - openingMax); + var groupedTerminalReached = groupedHealth <= 0; + for (var index = 0; index < update.HealthAdjustments.Count; index++) + { + var adjustment = update.HealthAdjustments[index]; + if ( + adjustment.DamageType == EDamageType.Burn + && adjustment.Amount < 0 + && adjustment.AttributeChanged + is EPlayerHealthChangeType.Health + or EPlayerHealthChangeType.Shield + ) + { + var groupWasAlive = !groupedTerminalReached; + while ( + index < update.HealthAdjustments.Count + && update.HealthAdjustments[index].DamageType + == EDamageType.Burn + && update.HealthAdjustments[index].Amount < 0 + && update.HealthAdjustments[index].AttributeChanged + is EPlayerHealthChangeType.Health + or EPlayerHealthChangeType.Shield + ) + { + var burnAdjustment = update.HealthAdjustments[index]; + if ( + burnAdjustment.AttributeChanged + == EPlayerHealthChangeType.Health + ) + { + var loss = -(long)burnAdjustment.Amount; + if (groupWasAlive) + { + AtomicBurnGroupCombatEffectiveHealthLossOnDeathEventFrames += + Math.Min(loss, Math.Max(0L, groupedHealth)); + } + groupedHealth += burnAdjustment.Amount; + if (groupedHealth <= 0) + groupedTerminalReached = true; + } + else if (groupWasAlive) + { + AtomicBurnGroupCombatEffectiveShieldConsumedOnDeathEventFrames += + -(long)burnAdjustment.Amount; + } + index++; + } + index--; + continue; + } + + if (adjustment.AttributeChanged != EPlayerHealthChangeType.Health) + continue; + if (adjustment.Amount <= 0) + { + groupedHealth += adjustment.Amount; + if (groupedHealth <= 0) + groupedTerminalReached = true; + continue; + } + + var groupedAccepted = Math.Min( + adjustment.Amount, + Math.Max(0L, closingMax - groupedHealth) + ); + groupedHealth += groupedAccepted; + } + } + } + + if (update != null) + { + foreach (var (attribute, transition) in update.Attributes) + state[attribute] = transition.CurrentValue; + } + if (hasDeathEvent) + { + DeathEvents++; + deathEvents++; + } + } + if (deathEvents > 1) + CombatantsWithMultipleDeathEvents++; + } + } +} + +internal sealed record OpeningActionObservation( + string BattleId, + string Action, + string? SourceId, + string? DirectSourceId, + string? TriggerSourceId, + string? Target +); + +internal sealed class AdjustmentAggregate +{ + public int Count { get; set; } + public long NetAmount { get; set; } + public long AbsoluteAmount { get; set; } + public int CriticalCount { get; set; } + public int ReducedCount { get; set; } +} + +internal sealed class TransitionAggregate +{ + public int Count { get; set; } + public long NetDelta { get; set; } + public long AbsoluteDelta { get; set; } +} + +internal sealed record SourceCoverage( + int ResolvedActions, + int UnresolvedActions, + IReadOnlyList MissingStatSourceIdentities, + IReadOnlyList UnresolvedActionSources, + IReadOnlyDictionary MultiSourceBattlesByStatus +); + +internal sealed class ScenarioCoverage +{ + public int BurnWithoutShield { get; set; } + public int BurnWithShield { get; set; } + public int BurnShieldUnknown { get; set; } + public int BurnHealthAndShieldAdjustment { get; set; } + public int BurnHealthOnlyAdjustment { get; set; } + public int BurnShieldOnlyAdjustment { get; set; } + public int PoisonWhileShieldPresent { get; set; } + public int PoisonShieldAdjustment { get; set; } + public int PoisonHealthAdjustment { get; set; } + public int RegenHealthAdjustment { get; set; } + public int RegenReachedMaxHealth { get; set; } + + internal void Observe(PeriodicFrameObservation frame) + { + var burnHealth = Loss(frame.Adjustments, "Burn", "Health"); + var burnShield = Loss(frame.Adjustments, "Burn", "Shield"); + var poisonHealth = Loss(frame.Adjustments, "Poison", "Health"); + var poisonShield = Loss(frame.Adjustments, "Poison", "Shield"); + var regenHealth = Gain(frame.Adjustments, "Regen", "Health"); + + if (burnHealth > 0 || burnShield > 0) + { + if (!frame.Before.Shield.HasValue) + BurnShieldUnknown++; + else if (frame.Before.Shield.Value > 0) + BurnWithShield++; + else + BurnWithoutShield++; + + if (burnHealth > 0 && burnShield > 0) + BurnHealthAndShieldAdjustment++; + else if (burnHealth > 0) + BurnHealthOnlyAdjustment++; + else + BurnShieldOnlyAdjustment++; + } + + if (poisonHealth > 0) + { + PoisonHealthAdjustment++; + if (frame.Before.Shield > 0) + PoisonWhileShieldPresent++; + } + if (poisonShield > 0) + PoisonShieldAdjustment++; + if (regenHealth > 0) + { + RegenHealthAdjustment++; + if ( + frame.After.Health.HasValue + && frame.After.HealthMax.HasValue + && frame.After.Health.Value == frame.After.HealthMax.Value + ) + { + RegenReachedMaxHealth++; + } + } + } + + private static long Loss( + IEnumerable adjustments, + string damageType, + string pool + ) => + -adjustments + .Where(adjustment => + adjustment.DamageType == damageType + && adjustment.Pool == pool + && adjustment.Amount < 0 + ) + .Sum(adjustment => (long)adjustment.Amount); + + private static long Gain( + IEnumerable adjustments, + string damageType, + string pool + ) => + adjustments + .Where(adjustment => + adjustment.DamageType == damageType + && adjustment.Pool == pool + && adjustment.Amount > 0 + ) + .Sum(adjustment => (long)adjustment.Amount); +} + +internal sealed record RuleCandidateCoverage( + CandidateCoverage BurnShieldBridge, + CandidateCoverage BurnDecay, + CandidateCoverage PoisonTick, + CandidateCoverage RegenTick, + PoolLedgerCoverage HealthLedger, + PoolLedgerCoverage ShieldLedger, + ApplicationCoverage Applications, + RealizedImpactTotals RealizedTotals +) +{ + internal static RuleCandidateCoverage Build(IReadOnlyList frames) + { + var burnBridge = new CandidateCounter(); + var burnDecay = new CandidateCounter(); + var poisonTick = new CandidateCounter(); + var regenTick = new CandidateCounter(); + var healthLedger = new PoolLedgerCounter(); + var shieldLedger = new PoolLedgerCounter(); + var applications = new ApplicationCoverage(); + var realizedTotals = new RealizedImpactTotals(); + + foreach (var frame in frames) + { + ObserveBurnBridge(frame, burnBridge); + ObserveBurnDecay(frame, burnDecay); + ObservePoisonTick(frame, poisonTick); + ObserveRegenTick(frame, regenTick); + ObserveHealthLedger(frame, healthLedger); + ObserveShieldLedger(frame, shieldLedger); + applications.Observe(frame); + realizedTotals.Observe(frame); + } + + return new RuleCandidateCoverage( + burnBridge.Build(), + burnDecay.Build(), + poisonTick.Build(), + regenTick.Build(), + healthLedger.Build(), + shieldLedger.Build(), + applications, + realizedTotals + ); + } + + private static void ObserveBurnBridge(PeriodicFrameObservation frame, CandidateCounter counter) + { + var healthDamage = Loss(frame, "Burn", "Health"); + var shieldConsumed = Loss(frame, "Burn", "Shield"); + if (healthDamage == 0 && shieldConsumed == 0) + return; + if (!frame.Before.Burn.HasValue || !frame.Before.Shield.HasValue) + { + counter.Skip(); + return; + } + + var burn = frame.Before.Burn.Value; + var shield = frame.Before.Shield.Value; + var expectedShield = Math.Min(shield, burn / 2); + var expectedHealth = Math.Max(0, burn - 2 * shield); + counter.Observe( + healthDamage == expectedHealth && shieldConsumed == expectedShield, + healthDamage + shieldConsumed + ); + } + + private static void ObserveBurnDecay(PeriodicFrameObservation frame, CandidateCounter counter) + { + if (Loss(frame, "Burn", "Health") == 0 && Loss(frame, "Burn", "Shield") == 0) + return; + if ( + !frame.Before.Burn.HasValue + || !frame.After.Burn.HasValue + || frame.Before.Burn.Value <= 0 + ) + { + counter.Skip(); + return; + } + if ( + frame.Actions.Any(action => action.Action is "PlayerBurnApply" or "PlayerBurnRemove") + || frame.Adjustments.Any(adjustment => adjustment.DamageType == "Heal") + ) + { + counter.Skip(); + return; + } + + var decay = Math.Max(1, (int)Math.Floor(frame.Before.Burn.Value * 0.03)); + counter.Observe(frame.After.Burn.Value == frame.Before.Burn.Value - decay, decay); + } + + private static void ObservePoisonTick(PeriodicFrameObservation frame, CandidateCounter counter) + { + var damage = Loss(frame, "Poison", "Health"); + if (damage == 0) + return; + if (!frame.Before.Poison.HasValue || !frame.After.Poison.HasValue) + { + counter.Skip(); + return; + } + + var expected = + frame.FrameIndex == 0 + && frame.Before.Poison.Value == 0 + && frame.Actions.Any(action => action.Action == "PlayerPoisonApply") + ? frame.After.Poison.Value + : frame.Before.Poison.Value; + counter.Observe(damage == expected, damage); + } + + private static void ObserveRegenTick(PeriodicFrameObservation frame, CandidateCounter counter) + { + var attempted = Gain(frame, "Regen", "Health"); + if (attempted == 0) + return; + if (!frame.Before.Regen.HasValue || frame.Before.Regen.Value <= 0) + { + counter.Skip(); + return; + } + + counter.Observe(attempted == frame.Before.Regen.Value, attempted); + } + + private static void ObserveHealthLedger( + PeriodicFrameObservation frame, + PoolLedgerCounter counter + ) + { + if ( + !frame.Before.Health.HasValue + || !frame.After.Health.HasValue + || !frame.Before.HealthMax.HasValue + || !frame.After.HealthMax.HasValue + ) + { + counter.Skip(); + return; + } + + var health = + frame.Before.Health.Value + + (frame.After.HealthMax.Value - frame.Before.HealthMax.Value); + long attemptedGain = 0; + long realizedGain = 0; + long loss = 0; + foreach ( + var adjustment in frame.Adjustments.Where(adjustment => adjustment.Pool == "Health") + ) + { + if (adjustment.Amount <= 0) + { + health += adjustment.Amount; + loss += -(long)adjustment.Amount; + continue; + } + + attemptedGain += adjustment.Amount; + var accepted = Math.Min( + adjustment.Amount, + Math.Max(0, frame.After.HealthMax.Value - health) + ); + health += accepted; + realizedGain += accepted; + } + + counter.Observe(health == frame.After.Health.Value, attemptedGain, realizedGain, loss); + } + + private static void ObserveShieldLedger( + PeriodicFrameObservation frame, + PoolLedgerCounter counter + ) + { + if (!frame.Before.Shield.HasValue || !frame.After.Shield.HasValue) + { + counter.Skip(); + return; + } + + long shield = frame.Before.Shield.Value; + long gain = 0; + long loss = 0; + foreach ( + var adjustment in frame.Adjustments.Where(adjustment => adjustment.Pool == "Shield") + ) + { + shield += adjustment.Amount; + if (adjustment.Amount > 0) + gain += adjustment.Amount; + else + loss += -(long)adjustment.Amount; + } + counter.Observe(shield == frame.After.Shield.Value, gain, gain, loss); + } + + private static long Loss(PeriodicFrameObservation frame, string type, string pool) => + -frame + .Adjustments.Where(adjustment => + adjustment.DamageType == type && adjustment.Pool == pool && adjustment.Amount < 0 + ) + .Sum(adjustment => (long)adjustment.Amount); + + private static long Gain(PeriodicFrameObservation frame, string type, string pool) => + frame + .Adjustments.Where(adjustment => + adjustment.DamageType == type && adjustment.Pool == pool && adjustment.Amount > 0 + ) + .Sum(adjustment => (long)adjustment.Amount); +} + +internal sealed record CandidateCoverage( + int Observed, + int Matched, + int Mismatched, + int Skipped, + long MatchedAmount, + long MismatchedAmount +); + +internal sealed class CandidateCounter +{ + private int _observed; + private int _matched; + private int _mismatched; + private int _skipped; + private long _matchedAmount; + private long _mismatchedAmount; + + internal void Observe(bool matched, long amount) + { + _observed++; + if (matched) + { + _matched++; + _matchedAmount += amount; + } + else + { + _mismatched++; + _mismatchedAmount += amount; + } + } + + internal void Skip() => _skipped++; + + internal CandidateCoverage Build() => + new(_observed, _matched, _mismatched, _skipped, _matchedAmount, _mismatchedAmount); +} + +internal sealed record PoolLedgerCoverage( + int Observed, + int Reconciled, + int Mismatched, + int Skipped, + long AttemptedGain, + long RealizedGain, + long Loss +); + +internal sealed class PoolLedgerCounter +{ + private int _observed; + private int _reconciled; + private int _mismatched; + private int _skipped; + private long _attemptedGain; + private long _realizedGain; + private long _loss; + + internal void Observe(bool reconciled, long attemptedGain, long realizedGain, long loss) + { + _observed++; + if (reconciled) + _reconciled++; + else + _mismatched++; + _attemptedGain += attemptedGain; + _realizedGain += realizedGain; + _loss += loss; + } + + internal void Skip() => _skipped++; + + internal PoolLedgerCoverage Build() => + new(_observed, _reconciled, _mismatched, _skipped, _attemptedGain, _realizedGain, _loss); +} + +internal sealed class ApplicationCoverage +{ + public int GainFrames { get; private set; } + public int SingleResolvedSource { get; private set; } + public int MultipleResolvedSources { get; private set; } + public int NoResolvedSource { get; private set; } + public int ApplyWithoutVisibleGain { get; private set; } + public int UnexpectedLossFrames { get; private set; } + + internal void Observe(PeriodicFrameObservation frame) + { + ObserveStatus(frame, "Burn", frame.Before.Burn, frame.After.Burn); + ObserveStatus(frame, "Poison", frame.Before.Poison, frame.After.Poison); + ObserveStatus(frame, "Regen", frame.Before.Regen, frame.After.Regen); + } + + private void ObserveStatus( + PeriodicFrameObservation frame, + string status, + int? before, + int? after + ) + { + if (!before.HasValue || !after.HasValue) + return; + + var actionName = $"Player{status}Apply"; + var sources = frame + .Actions.Where(action => action.Action == actionName) + .Select(action => action.ResolvedSourceId) + .Where(source => source != null) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var hasApply = frame.Actions.Any(action => action.Action == actionName); + var baseline = before.Value; + if ( + status == "Burn" + && (Loss(frame, "Burn", "Health") > 0 || Loss(frame, "Burn", "Shield") > 0) + && before.Value > 0 + ) + { + baseline -= Math.Max(1, (int)Math.Floor(before.Value * 0.03)); + } + var gain = after.Value - baseline; + if (gain > 0) + { + GainFrames++; + if (sources.Length == 1) + SingleResolvedSource++; + else if (sources.Length > 1) + MultipleResolvedSources++; + else + NoResolvedSource++; + return; + } + + if (hasApply) + ApplyWithoutVisibleGain++; + if (gain < 0) + UnexpectedLossFrames++; + } + + private static long Loss(PeriodicFrameObservation frame, string type, string pool) => + -frame + .Adjustments.Where(adjustment => + adjustment.DamageType == type && adjustment.Pool == pool && adjustment.Amount < 0 + ) + .Sum(adjustment => (long)adjustment.Amount); +} + +internal sealed class AttributionCoverage +{ + public string ModelVersion { get; } = PeriodicEffectAttribution.ModelVersion; + public int BattlesWithResults { get; private set; } + public int Results { get; private set; } + public int ExactResults { get; private set; } + public int ConstrainedResults { get; private set; } + public int ProportionalResults { get; private set; } + public SortedDictionary ResultsByKindAndProof { get; } = + new(StringComparer.Ordinal); + public SortedDictionary HealthByKind { get; } = new(StringComparer.Ordinal); + public SortedDictionary ShieldByKind { get; } = new(StringComparer.Ordinal); + public SortedDictionary HealthByKindAndProof { get; } = + new(StringComparer.Ordinal); + public SortedDictionary ShieldByKindAndProof { get; } = + new(StringComparer.Ordinal); + public int AllocationDecisions { get; private set; } + public int ExactAllocationDecisions { get; private set; } + public int ConstrainedAllocationDecisions { get; private set; } + public int ProportionalAllocationDecisions { get; private set; } + public SortedDictionary AllocationDecisionsByKindAndProof { get; } = + new(StringComparer.Ordinal); + public SortedDictionary AllocatedHealthByKindAndProof { get; } = + new(StringComparer.Ordinal); + public SortedDictionary AllocatedShieldByKindAndProof { get; } = + new(StringComparer.Ordinal); + public int GapFrames { get; private set; } + public int UnknownFrames { get; private set; } + public SortedDictionary UnknownFramesByKind { get; } = new(StringComparer.Ordinal); + public SortedDictionary UnattributedHealthByKind { get; } = + new(StringComparer.Ordinal); + public SortedDictionary UnattributedShieldByKind { get; } = + new(StringComparer.Ordinal); + public SortedDictionary UnattributedHealthByOrigin { get; } = + new(StringComparer.Ordinal); + public SortedDictionary UnattributedShieldByOrigin { get; } = + new(StringComparer.Ordinal); + public SortedDictionary UnattributedHealthByContext { get; } = + new(StringComparer.Ordinal); + public SortedDictionary UnattributedRegenByCandidateCount { get; } = + new(StringComparer.Ordinal); + public SortedDictionary UnattributedRegenByCandidateSurfaces { get; } = + new(StringComparer.Ordinal); + public SortedDictionary RegenRealizedBySpawnBaseline { get; } = + new(StringComparer.Ordinal); + public SortedDictionary RegenAttributedBySpawnBaseline { get; } = + new(StringComparer.Ordinal); + public SortedDictionary RegenAttributedBySourceSurfaces { get; } = + new(StringComparer.Ordinal); + public SortedDictionary RegenAttributedSpawnBaselineSources { get; } = + new(StringComparer.Ordinal); + public SortedDictionary RegenUnknownBySpawnBaseline { get; } = + new(StringComparer.Ordinal); + public long ExactHealthAmount { get; private set; } + public long ConstrainedHealthAmount { get; private set; } + public long ProportionalHealthAmount { get; private set; } + public long ExactShieldAmount { get; private set; } + public long ConstrainedShieldAmount { get; private set; } + public long ProportionalShieldAmount { get; private set; } + public long ExcludedStatusAbsentRegenHealth { get; private set; } + public int RegenFramesWithAttempt { get; private set; } + public int RegenDeadFrames { get; private set; } + public int RegenDeathEventFrames { get; private set; } + public int RegenDeadFlagWithoutEventFrames { get; private set; } + public int RegenDeathEventWithoutFlagFrames { get; private set; } + public long RegenAttemptedOnDeadFrames { get; private set; } + public long RegenPoolAcceptedOnDeadFrames { get; private set; } + public long RegenAttemptedOnDeathEventFrames { get; private set; } + public long RegenPoolAcceptedOnDeathEventFrames { get; private set; } + public long RegenRealizedOnDeathEventFrames { get; private set; } + public long RegenPoolAcceptedAfterNonPositiveHealthOnDeathEventFrames { get; private set; } + public long RegenPoolAcceptedAfterNonPositiveHealth { get; private set; } + public long RegenAttributedOnDeadFrames { get; private set; } + public long RegenUnknownOnDeadFrames { get; private set; } + public long RegenAttributedOnDeathEventFrames { get; private set; } + public long RegenUnknownOnDeathEventFrames { get; private set; } + public long RegenStableSpawnBaselineRealized { get; private set; } + public long RegenStableSpawnBaselineAttributed { get; private set; } + public long RegenStableSpawnBaselineUnknown { get; private set; } + public int RegenGapMeasurementMismatchFrames { get; private set; } + public long RegenGapMeasurementSignedDifference { get; private set; } + public long RegenGapMeasurementAbsoluteDifference { get; private set; } + + internal AttributionCoverage() + { + foreach (var kind in Enum.GetNames()) + { + UnknownFramesByKind[kind] = 0; + foreach (var proof in Enum.GetNames()) + { + var key = $"{kind}/{proof}"; + ResultsByKindAndProof[key] = 0; + HealthByKindAndProof[key] = 0; + ShieldByKindAndProof[key] = 0; + AllocationDecisionsByKindAndProof[key] = 0; + AllocatedHealthByKindAndProof[key] = 0; + AllocatedShieldByKindAndProof[key] = 0; + } + } + } + + internal void Observe( + IReadOnlyDictionary impacts, + PeriodicEffectAttributionDiagnostics diagnostics, + CombatSim combat, + IReadOnlyDictionary identities, + GameSim spawn + ) + { + var regenMeasurements = BuildRegenMeasurements(combat, spawn); + foreach (var pair in regenMeasurements) + { + var measurement = pair.Value; + RegenFramesWithAttempt++; + var baselineKey = DescribeRegenSpawnBaseline(pair.Key.Combatant, measurement); + RegenRealizedBySpawnBaseline[baselineKey] = + RegenRealizedBySpawnBaseline.GetValueOrDefault(baselineKey) + measurement.Realized; + if (IsUnobservableSpawnBaseline(measurement)) + RegenStableSpawnBaselineRealized += measurement.Realized; + if (measurement.IsPlayerDead) + { + RegenDeadFrames++; + RegenAttemptedOnDeadFrames += measurement.Attempted; + RegenPoolAcceptedOnDeadFrames += measurement.PoolAccepted; + } + if (measurement.HasDeathEvent) + { + RegenDeathEventFrames++; + RegenAttemptedOnDeathEventFrames += measurement.Attempted; + RegenPoolAcceptedOnDeathEventFrames += measurement.PoolAccepted; + RegenRealizedOnDeathEventFrames += measurement.Realized; + RegenPoolAcceptedAfterNonPositiveHealthOnDeathEventFrames += + measurement.PoolAcceptedAfterNonPositiveHealth; + } + if (measurement.IsPlayerDead && !measurement.HasDeathEvent) + RegenDeadFlagWithoutEventFrames++; + if (!measurement.IsPlayerDead && measurement.HasDeathEvent) + RegenDeathEventWithoutFlagFrames++; + RegenPoolAcceptedAfterNonPositiveHealth += + measurement.PoolAcceptedAfterNonPositiveHealth; + } + if (impacts.Count > 0) + BattlesWithResults++; + foreach ( + var (key, impact) in impacts.OrderBy(pair => pair.Key.SourceId, StringComparer.Ordinal) + ) + { + Results++; + var kind = key.Kind.ToString(); + var proof = impact.Proof.ToString(); + var proofKey = $"{kind}/{proof}"; + ResultsByKindAndProof[proofKey]++; + HealthByKind[kind] = HealthByKind.GetValueOrDefault(kind) + impact.HealthAmount; + ShieldByKind[kind] = ShieldByKind.GetValueOrDefault(kind) + impact.ShieldAmount; + HealthByKindAndProof[proofKey] += impact.HealthAmount; + ShieldByKindAndProof[proofKey] += impact.ShieldAmount; + switch (impact.Proof) + { + case CombatImpactPeriodicProof.Exact: + ExactResults++; + ExactHealthAmount += impact.HealthAmount; + ExactShieldAmount += impact.ShieldAmount; + break; + case CombatImpactPeriodicProof.Constrained: + ConstrainedResults++; + ConstrainedHealthAmount += impact.HealthAmount; + ConstrainedShieldAmount += impact.ShieldAmount; + break; + case CombatImpactPeriodicProof.Proportional: + ProportionalResults++; + ProportionalHealthAmount += impact.HealthAmount; + ProportionalShieldAmount += impact.ShieldAmount; + break; + } + } + + foreach (var allocation in diagnostics.Allocations) + { + AllocationDecisions++; + var allocationKey = $"{allocation.Kind}/{allocation.Proof}"; + AllocationDecisionsByKindAndProof[allocationKey]++; + AllocatedHealthByKindAndProof[allocationKey] += allocation.HealthAmount; + AllocatedShieldByKindAndProof[allocationKey] += allocation.ShieldAmount; + if ( + allocation.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (allocation.FrameIndex, allocation.Combatant), + out var allocationMeasurement + ) + ) + { + var baselineKey = DescribeRegenSpawnBaseline( + allocation.Combatant, + allocationMeasurement + ); + RegenAttributedBySpawnBaseline[baselineKey] = + RegenAttributedBySpawnBaseline.GetValueOrDefault(baselineKey) + + allocation.HealthAmount; + var surfaceKey = DescribeRegenAllocationSurface( + allocation, + combat, + identities, + spawn + ); + RegenAttributedBySourceSurfaces[surfaceKey] = + RegenAttributedBySourceSurfaces.GetValueOrDefault(surfaceKey) + + allocation.HealthAmount; + if (IsUnobservableSpawnBaseline(allocationMeasurement)) + { + RegenStableSpawnBaselineAttributed += allocation.HealthAmount; + var sourceKey = DescribeRegenAllocationSource( + allocation, + combat, + identities, + spawn + ); + RegenAttributedSpawnBaselineSources[sourceKey] = + RegenAttributedSpawnBaselineSources.GetValueOrDefault(sourceKey) + + allocation.HealthAmount; + } + if (allocationMeasurement.IsPlayerDead) + RegenAttributedOnDeadFrames += allocation.HealthAmount; + } + if ( + allocation.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (allocation.FrameIndex, allocation.Combatant), + out var deathEventAllocationMeasurement + ) + && deathEventAllocationMeasurement.HasDeathEvent + ) + { + RegenAttributedOnDeathEventFrames += allocation.HealthAmount; + } + switch (allocation.Proof) + { + case CombatImpactPeriodicProof.Exact: + ExactAllocationDecisions++; + break; + case CombatImpactPeriodicProof.Constrained: + ConstrainedAllocationDecisions++; + break; + case CombatImpactPeriodicProof.Proportional: + ProportionalAllocationDecisions++; + break; + } + } + + foreach (var gap in diagnostics.Gaps) + { + GapFrames++; + var kind = gap.Kind.ToString(); + var origin = gap.Origin.ToString(); + var isMeasuredSourceImpact = gap.Kind != CombatImpactPeriodicKind.Regen; + var bookedGapHealth = gap.HealthAmount; + if ( + gap.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (gap.FrameIndex, gap.Combatant), + out var measuredGap + ) + && gap.HealthAmount != measuredGap.Realized + ) + { + var difference = gap.HealthAmount - measuredGap.Realized; + RegenGapMeasurementMismatchFrames++; + RegenGapMeasurementSignedDifference += difference; + RegenGapMeasurementAbsoluteDifference += Math.Abs(difference); + } + if ( + gap.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (gap.FrameIndex, gap.Combatant), + out var regenMeasurement + ) + && regenMeasurement.HasSourceStatus + && regenMeasurement.HealthReconciled + ) + { + isMeasuredSourceImpact = true; + if ( + gap.Origin.HasFlag(PeriodicUnknownOrigin.HealthLedgerUnreconciled) + || gap.Origin.HasFlag(PeriodicUnknownOrigin.StatusStateAbsent) + ) + { + bookedGapHealth = regenMeasurement.Realized; + } + } + if (isMeasuredSourceImpact) + { + if ( + gap.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (gap.FrameIndex, gap.Combatant), + out var gapMeasurement + ) + && gapMeasurement.IsPlayerDead + ) + { + RegenUnknownOnDeadFrames += bookedGapHealth; + } + if ( + gap.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (gap.FrameIndex, gap.Combatant), + out var deathEventGapMeasurement + ) + && deathEventGapMeasurement.HasDeathEvent + ) + { + RegenUnknownOnDeathEventFrames += bookedGapHealth; + } + if (bookedGapHealth > 0 || gap.ShieldAmount > 0) + { + UnknownFrames++; + UnknownFramesByKind[kind]++; + } + UnattributedHealthByKind[kind] = + UnattributedHealthByKind.GetValueOrDefault(kind) + bookedGapHealth; + UnattributedShieldByKind[kind] = + UnattributedShieldByKind.GetValueOrDefault(kind) + gap.ShieldAmount; + if ( + gap.Kind == CombatImpactPeriodicKind.Regen + && regenMeasurements.TryGetValue( + (gap.FrameIndex, gap.Combatant), + out var unknownMeasurement + ) + ) + { + var baselineKey = + $"{origin}/" + + DescribeRegenSpawnBaseline(gap.Combatant, unknownMeasurement); + RegenUnknownBySpawnBaseline[baselineKey] = + RegenUnknownBySpawnBaseline.GetValueOrDefault(baselineKey) + + bookedGapHealth; + if (IsUnobservableSpawnBaseline(unknownMeasurement)) + RegenStableSpawnBaselineUnknown += bookedGapHealth; + } + } + else + { + ExcludedStatusAbsentRegenHealth += gap.HealthAmount; + } + UnattributedHealthByOrigin[origin] = + UnattributedHealthByOrigin.GetValueOrDefault(origin) + gap.HealthAmount; + UnattributedShieldByOrigin[origin] = + UnattributedShieldByOrigin.GetValueOrDefault(origin) + gap.ShieldAmount; + var frame = combat.Frames[gap.FrameIndex]; + var hasSameFrameApply = frame + .Events.OfType() + .Any(effect => + effect.ActionType == EActionCommandType.PlayerRegenApply + && effect.Target is EffectTargetPlayer player + && player.Target == gap.Combatant + ); + var context = + $"{origin}/{(isMeasuredSourceImpact ? "MeasuredSource" : "Excluded")}/" + + $"{(hasSameFrameApply ? "ApplySameFrame" : "NoApply")}/" + + $"{(gap.FrameIndex == 0 ? "Frame0" : "Later")}"; + UnattributedHealthByContext[context] = + UnattributedHealthByContext.GetValueOrDefault(context) + gap.HealthAmount; + if (gap.Kind == CombatImpactPeriodicKind.Regen && gap.HealthAmount > 0) + { + var candidateCount = combat.CardStats.Count(pair => + pair.Value.GetValueOrDefault(ECardStats.RegenAdded) > 0 + && identities.TryGetValue(pair.Key, out var identity) + && identity.Combatant == gap.Combatant + && identity.Type is ECardType.Item or ECardType.Skill + ); + var candidateKey = + $"{origin}/{(isMeasuredSourceImpact ? "MeasuredSource" : "Excluded")}/" + + candidateCount; + UnattributedRegenByCandidateCount[candidateKey] = + UnattributedRegenByCandidateCount.GetValueOrDefault(candidateKey) + + gap.HealthAmount; + var attributeCandidateCount = spawn.Cards.Count(pair => + pair.Value.Attributes.TryGetValue( + ECardAttributeType.RegenApplyAmount, + out var attribute + ) + && attribute.Value > 0 + && identities.TryGetValue(pair.Key, out var identity) + && identity.Combatant == gap.Combatant + && identity.Type is ECardType.Item or ECardType.Skill + ); + var eventCandidateIds = combat + .Frames.SelectMany(frame => frame.Events.OfType()) + .Where(effect => + effect.ActionType == EActionCommandType.PlayerRegenApply + && effect.Target is EffectTargetPlayer player + && player.Target == gap.Combatant + ) + .Select(effect => CorpusInventory.ResolveSource(effect, identities)) + .Where(sourceId => sourceId != null) + .Select(sourceId => sourceId!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var eventCandidateCount = eventCandidateIds.Length; + var priorEventCandidateCount = combat + .Frames.Take(gap.FrameIndex + 1) + .SelectMany(frame => frame.Events.OfType()) + .Where(effect => + effect.ActionType == EActionCommandType.PlayerRegenApply + && effect.Target is EffectTargetPlayer player + && player.Target == gap.Combatant + ) + .Select(effect => CorpusInventory.ResolveSource(effect, identities)) + .Where(sourceId => sourceId != null) + .Distinct(StringComparer.Ordinal) + .Count(); + var displayCandidateCount = combat + .CardStats.Where(pair => + pair.Value.GetValueOrDefault(ECardStats.RegenAdded) > 0 + && identities.TryGetValue(pair.Key, out var identity) + && identity.Combatant == gap.Combatant + && identity.Type is ECardType.Item or ECardType.Skill + ) + .Select(pair => pair.Key) + .Concat(eventCandidateIds) + .Distinct(StringComparer.Ordinal) + .Count(); + var surfaceKey = + $"{origin}/{(isMeasuredSourceImpact ? "MeasuredSource" : "Excluded")}/" + + $"stats:{candidateCount}/attributes:{attributeCandidateCount}/" + + $"events:{eventCandidateCount}/prior:{priorEventCandidateCount}/" + + $"display:{displayCandidateCount}"; + UnattributedRegenByCandidateSurfaces[surfaceKey] = + UnattributedRegenByCandidateSurfaces.GetValueOrDefault(surfaceKey) + + gap.HealthAmount; + } + } + } + + private static Dictionary< + (int FrameIndex, ECombatantId Combatant), + RegenFrameMeasurement + > BuildRegenMeasurements(CombatSim combat, GameSim spawn) + { + var result = new Dictionary<(int, ECombatantId), RegenFrameMeasurement>(); + var states = new Dictionary> + { + [ECombatantId.Player] = new Dictionary( + spawn.Player.Attributes + ), + [ECombatantId.Opponent] = new Dictionary( + spawn.Opponent.Attributes + ), + }; + var spawnRegen = new Dictionary + { + [ECombatantId.Player] = spawn.Player.Attributes.GetValueOrDefault( + EPlayerAttributeType.HealthRegen + ), + [ECombatantId.Opponent] = spawn.Opponent.Attributes.GetValueOrDefault( + EPlayerAttributeType.HealthRegen + ), + }; + var hasAnyRegenTransition = new Dictionary + { + [ECombatantId.Player] = false, + [ECombatantId.Opponent] = false, + }; + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + var state = states[combatant]; + foreach (var frame in combat.Frames) + { + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + if (update == null) + continue; + if (update.Attributes.ContainsKey(EPlayerAttributeType.HealthRegen)) + hasAnyRegenTransition[combatant] = true; + foreach (var (attribute, transition) in update.Attributes) + state.TryAdd(attribute, transition.PreviousValue); + } + if ( + !state.ContainsKey(EPlayerAttributeType.HealthMax) + && state.TryGetValue(EPlayerAttributeType.Health, out var health) + ) + { + state[EPlayerAttributeType.HealthMax] = health; + } + } + for (var frameIndex = 0; frameIndex < combat.Frames.Count; frameIndex++) + { + var frame = combat.Frames[frameIndex]; + foreach (var combatant in new[] { ECombatantId.Player, ECombatantId.Opponent }) + { + var state = states[combatant]; + var update = + combatant == ECombatantId.Player ? frame.PlayerUpdates : frame.OpponentUpdates; + if (update == null) + continue; + + var openingRegen = state.GetValueOrDefault(EPlayerAttributeType.HealthRegen); + update.Attributes.TryGetValue( + EPlayerAttributeType.HealthRegen, + out var regenTransition + ); + var hasFrameRegenTransition = regenTransition != null; + var closingRegen = regenTransition?.CurrentValue ?? openingRegen; + var attempted = update + .HealthAdjustments.Where(adjustment => + adjustment.DamageType == EDamageType.Regen + && adjustment.AttributeChanged == EPlayerHealthChangeType.Health + && adjustment.Amount > 0 + ) + .Sum(adjustment => (long)adjustment.Amount); + if (attempted > 0) + { + var hasDeathEvent = frame + .Events.OfType() + .Any(death => death.CombatantId == combatant); + var reconciled = TryMeasureAcceptedRegen( + state, + update, + hasDeathEvent, + out var poolAccepted, + out var realized, + out var poolAcceptedAfterNonPositiveHealth + ); + result[(frameIndex, combatant)] = new RegenFrameMeasurement( + attempted, + poolAccepted, + realized, + poolAcceptedAfterNonPositiveHealth, + reconciled, + openingRegen > 0 || closingRegen > 0, + update.IsPlayerDead, + hasDeathEvent, + spawnRegen[combatant], + openingRegen, + closingRegen, + hasFrameRegenTransition, + hasAnyRegenTransition[combatant] + ); + } + + foreach (var (attribute, transition) in update.Attributes) + state[attribute] = transition.CurrentValue; + } + } + return result; + } + + private static string DescribeRegenSpawnBaseline( + ECombatantId combatant, + RegenFrameMeasurement measurement + ) + { + var attemptRelation = + measurement.OpeningRegen <= 0 ? "attempt:opening-zero" + : measurement.Attempted == measurement.OpeningRegen ? "attempt:equals-opening" + : measurement.Attempted % measurement.OpeningRegen == 0 ? "attempt:multiple-opening" + : "attempt:other"; + return $"{combatant}/" + + $"spawn:{(measurement.SpawnRegen > 0 ? "positive" : "zero")}/" + + $"combat-transition:{(measurement.HasAnyRegenTransition ? "yes" : "no")}/" + + $"frame-transition:{(measurement.HasFrameRegenTransition ? "yes" : "no")}/" + + attemptRelation; + } + + private static bool IsUnobservableSpawnBaseline(RegenFrameMeasurement measurement) => + measurement.HealthReconciled + && measurement.SpawnRegen > 0 + && !measurement.HasAnyRegenTransition + && measurement.OpeningRegen > 0 + && measurement.Attempted == measurement.OpeningRegen; + + private static string DescribeRegenAllocationSource( + PeriodicAttributionAllocation allocation, + CombatSim combat, + IReadOnlyDictionary identities, + GameSim spawn + ) + { + identities.TryGetValue(allocation.SourceId, out var identity); + return $"{identity?.TemplateId ?? allocation.SourceId}/" + + DescribeRegenAllocationSurface(allocation, combat, identities, spawn); + } + + private static string DescribeRegenAllocationSurface( + PeriodicAttributionAllocation allocation, + CombatSim combat, + IReadOnlyDictionary identities, + GameSim spawn + ) + { + var hasStats = + combat.CardStats.TryGetValue(allocation.SourceId, out var stats) + && stats.GetValueOrDefault(ECardStats.RegenAdded) > 0; + var hasAttribute = + spawn.Cards.TryGetValue(allocation.SourceId, out var card) + && card.Attributes.TryGetValue( + ECardAttributeType.RegenApplyAmount, + out var regenApplyAmount + ) + && regenApplyAmount.Value > 0; + var eventFrames = combat + .Frames.Select((frame, index) => (frame, index)) + .Where(pair => pair.index <= allocation.FrameIndex) + .SelectMany(pair => pair.frame.Events.OfType()) + .Where(effect => + effect.ActionType == EActionCommandType.PlayerRegenApply + && effect.Target is EffectTargetPlayer player + && player.Target == allocation.Combatant + && CorpusInventory.ResolveSource(effect, identities) == allocation.SourceId + ) + .Count(); + return $"stats:{(hasStats ? "yes" : "no")}/" + + $"attribute:{(hasAttribute ? "yes" : "no")}/" + + $"prior-events:{(eventFrames > 0 ? "yes" : "no")}"; + } + + private static bool TryMeasureAcceptedRegen( + IReadOnlyDictionary state, + CombatSimPlayerUpdate update, + bool combatantDied, + out long poolAccepted, + out long realized, + out long poolAcceptedAfterNonPositiveHealth + ) + { + poolAccepted = 0; + realized = 0; + poolAcceptedAfterNonPositiveHealth = 0; + if ( + !state.TryGetValue(EPlayerAttributeType.Health, out var openingHealth) + || !state.TryGetValue(EPlayerAttributeType.HealthMax, out var openingMax) + ) + { + return false; + } + + var closingMax = update.Attributes.TryGetValue( + EPlayerAttributeType.HealthMax, + out var maxTransition + ) + ? maxTransition.CurrentValue + : openingMax; + long health = openingHealth + (closingMax - openingMax); + var terminalReached = combatantDied && health <= 0; + foreach ( + var adjustment in update.HealthAdjustments.Where(adjustment => + adjustment.AttributeChanged == EPlayerHealthChangeType.Health + ) + ) + { + if (adjustment.Amount <= 0) + { + health += adjustment.Amount; + if (combatantDied && health <= 0) + terminalReached = true; + continue; + } + var accepted = Math.Min(adjustment.Amount, Math.Max(0L, closingMax - health)); + health += accepted; + if (adjustment.DamageType == EDamageType.Regen) + { + poolAccepted += accepted; + if (terminalReached) + poolAcceptedAfterNonPositiveHealth += accepted; + if (!combatantDied || !terminalReached) + realized += accepted; + } + } + + var closingHealth = update.Attributes.TryGetValue( + EPlayerAttributeType.Health, + out var healthTransition + ) + ? healthTransition.CurrentValue + : health; + return health == closingHealth; + } + + private readonly record struct RegenFrameMeasurement( + long Attempted, + long PoolAccepted, + long Realized, + long PoolAcceptedAfterNonPositiveHealth, + bool HealthReconciled, + bool HasSourceStatus, + bool IsPlayerDead, + bool HasDeathEvent, + int SpawnRegen, + int OpeningRegen, + int ClosingRegen, + bool HasFrameRegenTransition, + bool HasAnyRegenTransition + ); +} + +internal sealed class RealizedImpactTotals +{ + public long BurnHealthDamage { get; private set; } + public long BurnShieldConsumed { get; private set; } + public long PoisonHealthDamage { get; private set; } + public long RegenAttempted { get; private set; } + public long RegenRealized { get; private set; } + + internal void Observe(PeriodicFrameObservation frame) + { + BurnHealthDamage += EffectiveHealthLoss(frame, "Burn"); + BurnShieldConsumed += EffectiveBurnShieldLoss(frame); + PoisonHealthDamage += EffectiveHealthLoss(frame, "Poison"); + + var attempted = Gain(frame, "Regen", "Health"); + if ( + attempted <= 0 + || ((frame.Before.Regen ?? 0) <= 0 && (frame.After.Regen ?? 0) <= 0) + || !frame.Before.Health.HasValue + || !frame.After.Health.HasValue + || !frame.Before.HealthMax.HasValue + || !frame.After.HealthMax.HasValue + ) + { + return; + } + + long health = + frame.Before.Health.Value + + (frame.After.HealthMax.Value - frame.Before.HealthMax.Value); + var terminalReached = frame.HasDeathEvent && health <= 0; + long realized = 0; + foreach ( + var adjustment in frame.Adjustments.Where(adjustment => adjustment.Pool == "Health") + ) + { + if (adjustment.Amount <= 0) + { + health += adjustment.Amount; + if (frame.HasDeathEvent && health <= 0) + terminalReached = true; + continue; + } + + var accepted = Math.Min( + adjustment.Amount, + Math.Max(0, frame.After.HealthMax.Value - health) + ); + health += accepted; + if (adjustment.DamageType == "Regen" && (!frame.HasDeathEvent || !terminalReached)) + { + realized += accepted; + } + } + if (health != frame.After.Health.Value) + return; + + RegenAttempted += attempted; + RegenRealized += realized; + } + + private static long Loss(PeriodicFrameObservation frame, string type, string pool) => + -frame + .Adjustments.Where(adjustment => + adjustment.DamageType == type && adjustment.Pool == pool && adjustment.Amount < 0 + ) + .Sum(adjustment => (long)adjustment.Amount); + + private static long EffectiveHealthLoss(PeriodicFrameObservation frame, string type) + { + var raw = Loss(frame, type, "Health"); + if ( + !frame.HasDeathEvent + || !frame.Before.Health.HasValue + || !frame.Before.HealthMax.HasValue + || !frame.After.HealthMax.HasValue + ) + { + return raw; + } + + long health = + frame.Before.Health.Value + + (frame.After.HealthMax.Value - frame.Before.HealthMax.Value); + var terminalReached = health <= 0; + var effective = 0L; + foreach ( + var adjustment in frame.Adjustments.Where(adjustment => adjustment.Pool == "Health") + ) + { + if (adjustment.Amount <= 0) + { + var loss = -(long)adjustment.Amount; + if (adjustment.DamageType == type) + effective += terminalReached ? 0L : Math.Min(loss, Math.Max(0L, health)); + health += adjustment.Amount; + if (health <= 0) + terminalReached = true; + continue; + } + + var accepted = Math.Min( + adjustment.Amount, + Math.Max(0L, frame.After.HealthMax.Value - health) + ); + health += accepted; + } + return effective; + } + + private static long EffectiveBurnShieldLoss(PeriodicFrameObservation frame) + { + var raw = Loss(frame, "Burn", "Shield"); + if ( + !frame.HasDeathEvent + || !frame.Before.Health.HasValue + || !frame.Before.HealthMax.HasValue + || !frame.After.HealthMax.HasValue + ) + { + return raw; + } + + long health = + frame.Before.Health.Value + + (frame.After.HealthMax.Value - frame.Before.HealthMax.Value); + var terminalReached = health <= 0; + var effective = 0L; + for (var index = 0; index < frame.Adjustments.Count; index++) + { + var adjustment = frame.Adjustments[index]; + if ( + adjustment.DamageType == "Burn" + && adjustment.Amount < 0 + && adjustment.Pool is "Health" or "Shield" + ) + { + var groupWasAlive = !terminalReached; + while ( + index < frame.Adjustments.Count + && frame.Adjustments[index].DamageType == "Burn" + && frame.Adjustments[index].Amount < 0 + && frame.Adjustments[index].Pool is "Health" or "Shield" + ) + { + var burnAdjustment = frame.Adjustments[index]; + if (burnAdjustment.Pool == "Health") + { + health += burnAdjustment.Amount; + if (health <= 0) + terminalReached = true; + } + else if (groupWasAlive) + { + effective += -(long)burnAdjustment.Amount; + } + index++; + } + index--; + continue; + } + if (adjustment.Pool != "Health") + continue; + if (adjustment.Amount <= 0) + { + health += adjustment.Amount; + if (health <= 0) + terminalReached = true; + continue; + } + + var accepted = Math.Min( + adjustment.Amount, + Math.Max(0L, frame.After.HealthMax.Value - health) + ); + health += accepted; + } + return effective; + } + + private static long Gain(PeriodicFrameObservation frame, string type, string pool) => + frame + .Adjustments.Where(adjustment => + adjustment.DamageType == type && adjustment.Pool == pool && adjustment.Amount > 0 + ) + .Sum(adjustment => (long)adjustment.Amount); +} + +internal sealed record BattleInventorySummary( + string BattleId, + int Frames, + int PeriodicFrames, + IReadOnlyDictionary StatSources, + IReadOnlyDictionary StatAmounts, + int MissingStatSourceIdentities +); + +internal sealed record PeriodicFrameObservation( + string BattleId, + int FrameIndex, + string Combatant, + AttributeSnapshot Before, + AttributeSnapshot After, + IReadOnlyList Adjustments, + IReadOnlyList Actions, + IReadOnlyList ContextActions, + bool HasDeathEvent +); + +internal sealed record AttributeSnapshot( + int? Burn, + int? Poison, + int? Regen, + int? Health, + int? HealthMax, + int? Shield, + int? FlatDamageReduction, + int? PercentDamageReduction +); + +internal sealed record AdjustmentObservation( + string DamageType, + string Pool, + int Amount, + bool IsCritical, + bool IsDamageReduced +); + +internal sealed record ActionObservation( + string Action, + string? ResolvedSourceId, + string? DirectSourceId, + string? TriggerSourceId +); diff --git a/tests/PeriodicEffectAttribution.Corpus/PeriodicEffectAttribution.Corpus.csproj b/tests/PeriodicEffectAttribution.Corpus/PeriodicEffectAttribution.Corpus.csproj new file mode 100644 index 00000000..72137202 --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/PeriodicEffectAttribution.Corpus.csproj @@ -0,0 +1,47 @@ + + + Exe + net10.0 + enable + enable + + + + + + + $(ManagedPath)/BazaarGameShared.dll + + + + + + + + + + + + + diff --git a/tests/PeriodicEffectAttribution.Corpus/Program.cs b/tests/PeriodicEffectAttribution.Corpus/Program.cs new file mode 100644 index 00000000..dc8fbd56 --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/Program.cs @@ -0,0 +1,664 @@ +#nullable enable +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using BazaarGameShared; +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Infra.Messages; +using BazaarGameShared.Infra.Messages.CombatSimEvents; +using BazaarGameShared.Infra.Messages.GameSimEvents; +using BazaarPlusPlus.Game.CombatReplay; +using BazaarPlusPlus.Infrastructure; +using BazaarPlusPlus.ModApi; +using BazaarPlusPlus.ModApi.Bundle; +using MessagePack; + +const string corpusEnvironmentVariable = "BPP_REPLAY_CORPUS"; +const string bundleSampleLimitEnvironmentVariable = "BPP_BUNDLE_CORPUS_LIMIT"; +const string evidencePathEnvironmentVariable = "BPP_PERIODIC_EVIDENCE_PATH"; +const int defaultBundleSampleLimit = 100; + +if (args.Length > 2) +{ + throw new ArgumentException( + "Usage: PeriodicEffectAttribution.Corpus [replay-corpus-path] [report-path]" + ); +} + +var corpusPath = + args.Length >= 1 ? args[0] : Environment.GetEnvironmentVariable(corpusEnvironmentVariable); +if (string.IsNullOrWhiteSpace(corpusPath)) +{ + Console.WriteLine( + $"Periodic-effect corpus acceptance skipped; pass a path or set {corpusEnvironmentVariable}." + ); + return; +} + +if (!Directory.Exists(corpusPath)) + throw new DirectoryNotFoundException($"Replay corpus does not exist: {corpusPath}"); + +var reportPath = + args.Length == 2 + ? Path.GetFullPath(args[1]) + : Path.Combine(AppContext.BaseDirectory, "periodic-effect-corpus-report.json"); +var loadResult = LoadCorpus(corpusPath); +var replays = loadResult.Replays; +var invalidPayloads = loadResult.InvalidPayloads; + +var sample = replays + .Select(replay => new BoundarySample( + replay.BattleId, + replay.Combat.Frames.Count, + replay.Spawn.Events.OfType().Count(), + CountPeriodicStats(replay.Combat), + CountPeriodicAdjustments(replay.Combat) + )) + .FirstOrDefault(candidate => + candidate.SpawnedCards > 0 + && (candidate.PeriodicStats > 0 || candidate.PeriodicAdjustments > 0) + ); +if (sample == null) +{ + throw new InvalidOperationException( + $"Corpus contained {replays.Count} replays but no non-vacuous periodic-effect sample. " + + $"Invalid payloads: {string.Join(",", invalidPayloads)}" + ); +} + +var forbiddenAssemblies = AppDomain + .CurrentDomain.GetAssemblies() + .Select(assembly => assembly.GetName().Name ?? string.Empty) + .Where(name => + name.Equals("Assembly-CSharp", StringComparison.Ordinal) + || name.StartsWith("UnityEngine", StringComparison.Ordinal) + ) + .Order(StringComparer.Ordinal) + .ToArray(); +if (forbiddenAssemblies.Length > 0) +{ + throw new InvalidOperationException( + $"Pure replay host loaded forbidden assemblies: {string.Join(",", forbiddenAssemblies)}" + ); +} + +var jsonOptions = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, +}; +var report = CorpusInventory.Build(replays, invalidPayloads); +var reportJson = JsonSerializer.Serialize(report, jsonOptions); +var repeatedJson = JsonSerializer.Serialize( + CorpusInventory.Build(replays, invalidPayloads), + jsonOptions +); +if (!string.Equals(reportJson, repeatedJson, StringComparison.Ordinal)) + throw new InvalidOperationException("P1 inventory is not deterministic across repeated runs."); + +Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!); +File.WriteAllText(reportPath, reportJson + Environment.NewLine); +var configuredEvidencePath = Environment.GetEnvironmentVariable(evidencePathEnvironmentVariable); +var evidencePath = string.IsNullOrWhiteSpace(configuredEvidencePath) + ? Path.Combine( + Path.GetDirectoryName(reportPath)!, + Path.GetFileNameWithoutExtension(reportPath) + ".evidence.json" + ) + : Path.GetFullPath(configuredEvidencePath); +var evidence = BuildEvidenceSnapshot(report, loadResult, reportJson); +Directory.CreateDirectory(Path.GetDirectoryName(evidencePath)!); +File.WriteAllText( + evidencePath, + JsonSerializer.Serialize(evidence, jsonOptions) + Environment.NewLine +); +Validate(report); + +Console.WriteLine( + $"P0 crossed battle={sample.BattleId} source={loadResult.SourceKind} " + + $"source_count={loadResult.SourceCount} corpus={replays.Count} frames={sample.Frames} " + + $"spawned={sample.SpawnedCards} periodic_stats={sample.PeriodicStats} " + + $"periodic_adjustments={sample.PeriodicAdjustments} invalid={invalidPayloads.Count}" +); +Console.WriteLine($"P1 evidence snapshot={evidencePath}"); + +static object BuildEvidenceSnapshot( + CorpusInventoryReport report, + CorpusLoadResult loadResult, + string reportJson +) +{ + var reportBytes = Encoding.UTF8.GetBytes(reportJson + Environment.NewLine); + return new + { + EvidenceSchemaVersion = 1, + CorpusSchemaVersion = report.SchemaVersion, + report.Attribution.ModelVersion, + loadResult.SourceKind, + loadResult.SourceCount, + SourceArtifacts = loadResult.SourceArtifacts, + BattleIds = report + .BattlesSummary.Select(battle => battle.BattleId) + .Order(StringComparer.Ordinal) + .ToArray(), + FullReportSha256 = Convert.ToHexString(SHA256.HashData(reportBytes)).ToLowerInvariant(), + report.Battles, + report.Frames, + PeriodicFrames = report.PeriodicFrames.Count, + report.PeriodicAdjustmentCount, + report.Adjustments, + report.Sources, + Rules = new + { + report.Rules.BurnShieldBridge, + report.Rules.BurnDecay, + report.Rules.PoisonTick, + report.Rules.RegenTick, + report.Rules.HealthLedger, + report.Rules.ShieldLedger, + report.Rules.RealizedTotals, + }, + report.TerminalImpact, + Attribution = new + { + report.Attribution.Results, + report.Attribution.ExactResults, + report.Attribution.ConstrainedResults, + report.Attribution.ProportionalResults, + report.Attribution.ResultsByKindAndProof, + report.Attribution.HealthByKind, + report.Attribution.ShieldByKind, + report.Attribution.HealthByKindAndProof, + report.Attribution.ShieldByKindAndProof, + report.Attribution.AllocationDecisions, + report.Attribution.AllocationDecisionsByKindAndProof, + report.Attribution.AllocatedHealthByKindAndProof, + report.Attribution.AllocatedShieldByKindAndProof, + report.Attribution.UnattributedHealthByKind, + report.Attribution.UnattributedShieldByKind, + report.Attribution.UnattributedHealthByOrigin, + report.Attribution.UnattributedShieldByOrigin, + report.Attribution.UnattributedHealthByContext, + report.Attribution.UnattributedRegenByCandidateSurfaces, + report.Attribution.RegenRealizedBySpawnBaseline, + report.Attribution.RegenAttributedBySpawnBaseline, + report.Attribution.RegenAttributedBySourceSurfaces, + report.Attribution.RegenAttributedSpawnBaselineSources, + report.Attribution.RegenUnknownBySpawnBaseline, + report.Attribution.RegenGapMeasurementMismatchFrames, + report.Attribution.RegenGapMeasurementSignedDifference, + report.Attribution.RegenGapMeasurementAbsoluteDifference, + report.Attribution.RegenStableSpawnBaselineRealized, + report.Attribution.RegenStableSpawnBaselineAttributed, + report.Attribution.RegenStableSpawnBaselineUnknown, + }, + }; +} +Console.WriteLine( + $"P1 inventory report={reportPath} battles={report.Battles} frames={report.Frames} " + + $"periodic_frames={report.PeriodicFrames.Count} adjustments={report.PeriodicAdjustmentCount} " + + $"resolved_actions={report.Sources.ResolvedActions} " + + $"unresolved_actions={report.Sources.UnresolvedActions} " + + $"missing_stat_identities={report.Sources.MissingStatSourceIdentities.Count}" +); + +static int CountPeriodicStats(CombatSim combat) => + combat.CardStats.Values.Count(stats => + stats.ContainsKey(ECardStats.BurnAdded) + || stats.ContainsKey(ECardStats.PoisonAdded) + || stats.ContainsKey(ECardStats.RegenAdded) + ); + +static int CountPeriodicAdjustments(CombatSim combat) => + combat.Frames.Sum(frame => + CountUpdatePeriodicAdjustments(frame.PlayerUpdates) + + CountUpdatePeriodicAdjustments(frame.OpponentUpdates) + ); + +static int CountUpdatePeriodicAdjustments(CombatSimPlayerUpdate? update) => + update?.HealthAdjustments.Count(adjustment => + adjustment.DamageType is EDamageType.Burn or EDamageType.Poison or EDamageType.Regen + ) ?? 0; + +static CorpusLoadResult LoadCorpus(string corpusPath) +{ + var bundleRoot = Path.Combine(corpusPath, "run-bundles"); + if (!Directory.Exists(bundleRoot)) + return LoadReplayPayloadCorpus(corpusPath); + + var bundlePaths = Directory + .EnumerateFiles(bundleRoot, "*.mpack.gz", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal) + .Take(BundleSampleLimit()) + .ToArray(); + if (bundlePaths.Length == 0) + throw new InvalidOperationException($"Run-bundle cache is empty: {bundleRoot}"); + return LoadBundleCorpus(bundlePaths); +} + +static int BundleSampleLimit() +{ + var configured = Environment.GetEnvironmentVariable(bundleSampleLimitEnvironmentVariable); + return int.TryParse(configured, out var limit) && limit > 0 ? limit : defaultBundleSampleLimit; +} + +static CorpusLoadResult LoadReplayPayloadCorpus(string corpusPath) +{ + var store = new CombatReplayPayloadStore(corpusPath); + var battleIds = store.ListBattleIds().Order(StringComparer.Ordinal).ToArray(); + if (battleIds.Length == 0) + throw new InvalidOperationException($"Replay corpus is empty: {corpusPath}"); + + var replays = new List(); + var invalidPayloads = new List(); + foreach (var battleId in battleIds) + { + var loaded = store.LoadDetailed(battleId); + if (loaded.Status != FileBackedPayloadLoadStatus.Loaded || loaded.Payload == null) + { + invalidPayloads.Add($"{battleId}:{loaded.Status}"); + continue; + } + AddReplay( + replays, + invalidPayloads, + battleId, + loaded.Payload.SpawnMessageBytes, + loaded.Payload.CombatMessageBytes + ); + } + + return new CorpusLoadResult( + "replay_payloads", + battleIds.Length, + replays, + invalidPayloads, + battleIds.Select(battleId => new CorpusSourceArtifact(battleId, null)).ToArray() + ); +} + +static CorpusLoadResult LoadBundleCorpus(IReadOnlyList bundlePaths) +{ + var replays = new List(); + var invalidPayloads = new List(); + var sourceArtifacts = new List(); + var seenBattleIds = new HashSet(StringComparer.Ordinal); + foreach (var bundlePath in bundlePaths) + { + var bundleName = Path.GetFileName(bundlePath); + byte[] bundleBytes; + try + { + bundleBytes = File.ReadAllBytes(bundlePath); + sourceArtifacts.Add( + new CorpusSourceArtifact( + Path.GetFileName(bundlePath), + Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant() + ) + ); + } + catch (Exception exception) + { + invalidPayloads.Add($"{bundleName}:{exception.GetType().Name}"); + continue; + } + + var opened = RunBundleV5Contract.Open(bundleBytes); + if (opened.Succeeded && opened.Value != null) + { + foreach (var battle in opened.Value.Payload.Battles) + AddBundleReplay( + replays, + invalidPayloads, + seenBattleIds, + battle.BattleId, + battle.Replay + ); + continue; + } + + if ( + !MessagePackGzipCodec.TryDeserialize( + bundleBytes, + out var contractless, + out var contractlessError + ) + || contractless == null + ) + { + invalidPayloads.Add( + $"{bundleName}:{opened.FailureKind?.ToString() ?? "OpenFailed"}/" + + $"{contractlessError ?? "ContractlessOpenFailed"}" + ); + continue; + } + foreach (var battle in contractless.Battles) + { + AddContractlessBundleReplay( + replays, + invalidPayloads, + seenBattleIds, + battle.BattleId, + battle.ReplayPayload + ); + } + } + + if (replays.Count == 0) + throw new InvalidOperationException("Sampled run bundles contained no replay payloads."); + return new CorpusLoadResult( + "run_bundles", + bundlePaths.Count, + replays, + invalidPayloads, + sourceArtifacts.OrderBy(artifact => artifact.Name, StringComparer.Ordinal).ToArray() + ); +} + +static void AddBundleReplay( + ICollection replays, + ICollection invalidPayloads, + ISet seenBattleIds, + string battleId, + BattleReplayV5? replay +) +{ + if ( + string.IsNullOrWhiteSpace(battleId) + || replay == null + || replay.SpawnMessageBytes.Length == 0 + || replay.CombatMessageBytes.Length == 0 + || !seenBattleIds.Add(battleId) + ) + { + return; + } + AddReplay( + replays, + invalidPayloads, + battleId, + replay.SpawnMessageBytes, + replay.CombatMessageBytes + ); +} + +static void AddContractlessBundleReplay( + ICollection replays, + ICollection invalidPayloads, + ISet seenBattleIds, + string battleId, + ContractlessReplayPayload? replay +) +{ + if ( + string.IsNullOrWhiteSpace(battleId) + || replay == null + || replay.SpawnMessageBytes.Length == 0 + || replay.CombatMessageBytes.Length == 0 + || !seenBattleIds.Add(battleId) + ) + { + return; + } + AddReplay( + replays, + invalidPayloads, + battleId, + replay.SpawnMessageBytes, + replay.CombatMessageBytes + ); +} + +static void AddReplay( + ICollection replays, + ICollection invalidPayloads, + string battleId, + ReadOnlyMemory spawnBytes, + ReadOnlyMemory combatBytes +) +{ + try + { + var spawnMessage = MessagePackSerializer.Deserialize( + spawnBytes, + MessagePackConfig.Options + ); + var combatMessage = MessagePackSerializer.Deserialize( + combatBytes, + MessagePackConfig.Options + ); + replays.Add(new ReplayObservationInput(battleId, spawnMessage.Data, combatMessage.Data)); + } + catch (Exception exception) + { + invalidPayloads.Add($"{battleId}:{exception.GetType().Name}"); + } +} + +static void Validate(CorpusInventoryReport report) +{ + if (report.InvalidPayloads.Count > 0) + throw new InvalidOperationException( + $"Corpus contained invalid payloads: {string.Join(",", report.InvalidPayloads)}" + ); + if (report.Sources.ResolvedActions == 0 || report.Sources.MissingStatSourceIdentities.Count > 0) + { + throw new InvalidOperationException( + "Periodic source inventory was vacuous or missed an authoritative CardStats identity." + ); + } + if (report.Scenarios.PoisonShieldAdjustment != 0) + throw new InvalidOperationException("Poison unexpectedly consumed Shield in the corpus."); + + foreach ( + var (name, candidate) in new[] + { + ("Burn Shield bridge", report.Rules.BurnShieldBridge), + ("Burn decay", report.Rules.BurnDecay), + ("Poison tick", report.Rules.PoisonTick), + ("Regen tick", report.Rules.RegenTick), + } + ) + { + if (candidate.Observed == 0) + { + throw new InvalidOperationException( + $"{name} diagnostic was vacuous: observed={candidate.Observed}." + ); + } + } + + if (report.Rules.ShieldLedger.Mismatched != 0) + throw new InvalidOperationException("Shield pool ledger did not reconcile."); + if ( + report.TerminalImpact.CombatantsWithMultipleDeathEvents != 0 + || report.TerminalImpact.FramesAfterFirstDeathEvent != 0 + || report.TerminalImpact.PeriodicAdjustmentFramesAfterFirstDeathEvent != 0 + ) + { + throw new InvalidOperationException( + "Terminal periodic-impact policy encountered a replay that continued after death." + ); + } + if (report.Attribution.Results == 0) + throw new InvalidOperationException("Attribution model produced no source results."); + if ( + report.Attribution.Results + != report.Attribution.ExactResults + + report.Attribution.ConstrainedResults + + report.Attribution.ProportionalResults + ) + { + throw new InvalidOperationException( + "Attribution result counts did not reconcile across proof classes." + ); + } + if ( + report.Attribution.HealthByKind.Values.Sum() + != report.Attribution.ExactHealthAmount + + report.Attribution.ConstrainedHealthAmount + + report.Attribution.ProportionalHealthAmount + || report.Attribution.ShieldByKind.Values.Sum() + != report.Attribution.ExactShieldAmount + + report.Attribution.ConstrainedShieldAmount + + report.Attribution.ProportionalShieldAmount + ) + { + throw new InvalidOperationException( + "Attributed amounts did not reconcile across proof classes." + ); + } + if ( + report.Attribution.AllocationDecisions + != report.Attribution.ExactAllocationDecisions + + report.Attribution.ConstrainedAllocationDecisions + + report.Attribution.ProportionalAllocationDecisions + ) + { + throw new InvalidOperationException( + "Allocation decision counts did not reconcile across proof classes." + ); + } + if ( + report.Attribution.AllocatedHealthByKindAndProof.Values.Sum() + != report.Attribution.HealthByKind.Values.Sum() + || report.Attribution.AllocatedShieldByKindAndProof.Values.Sum() + != report.Attribution.ShieldByKind.Values.Sum() + ) + { + throw new InvalidOperationException( + "Allocation decision amounts did not reconcile with attributed totals." + ); + } + if ( + report.Attribution.RegenPoolAcceptedOnDeathEventFrames + != report.Attribution.RegenRealizedOnDeathEventFrames + + report.Attribution.RegenPoolAcceptedAfterNonPositiveHealthOnDeathEventFrames + ) + { + throw new InvalidOperationException( + "Death-frame Regen did not partition into pre-lethal realized and post-lethal excluded amounts." + ); + } + var totals = report.Rules.RealizedTotals; + RequireTerminalMeasurement( + "Burn Health", + report.Adjustments.GetValueOrDefault("Burn/Health/Loss")?.AbsoluteAmount ?? 0, + report.TerminalImpact.PeriodicHealthLossOnDeathEventFrames.GetValueOrDefault("Burn"), + report.TerminalImpact.AtomicBurnGroupCombatEffectiveHealthLossOnDeathEventFrames, + totals.BurnHealthDamage + ); + RequireTerminalMeasurement( + "Burn Shield", + report.Adjustments.GetValueOrDefault("Burn/Shield/Loss")?.AbsoluteAmount ?? 0, + report.TerminalImpact.BurnShieldConsumedOnDeathEventFrames, + report.TerminalImpact.AtomicBurnGroupCombatEffectiveShieldConsumedOnDeathEventFrames, + totals.BurnShieldConsumed + ); + RequireTerminalMeasurement( + "Poison Health", + report.Adjustments.GetValueOrDefault("Poison/Health/Loss")?.AbsoluteAmount ?? 0, + report.TerminalImpact.PeriodicHealthLossOnDeathEventFrames.GetValueOrDefault("Poison"), + report.TerminalImpact.CombatEffectivePeriodicHealthLossOnDeathEventFrames.GetValueOrDefault( + "Poison" + ), + totals.PoisonHealthDamage + ); + RequireConservation( + "Burn Health", + report.Attribution.HealthByKind.GetValueOrDefault("Burn"), + report.Attribution.UnattributedHealthByKind.GetValueOrDefault("Burn"), + totals.BurnHealthDamage + ); + RequireConservation( + "Burn Shield", + report.Attribution.ShieldByKind.GetValueOrDefault("Burn"), + report.Attribution.UnattributedShieldByKind.GetValueOrDefault("Burn"), + totals.BurnShieldConsumed + ); + RequireConservation( + "Poison Health", + report.Attribution.HealthByKind.GetValueOrDefault("Poison"), + report.Attribution.UnattributedHealthByKind.GetValueOrDefault("Poison"), + totals.PoisonHealthDamage + ); + RequireConservation( + "Regen Health", + report.Attribution.HealthByKind.GetValueOrDefault("Regen"), + report.Attribution.UnattributedHealthByKind.GetValueOrDefault("Regen"), + totals.RegenRealized + ); + if ( + report.Attribution.ShieldByKind.GetValueOrDefault("Poison") != 0 + || report.Attribution.ShieldByKind.GetValueOrDefault("Regen") != 0 + ) + { + throw new InvalidOperationException( + "Attribution model booked non-Burn periodic Shield impact." + ); + } + var attributedRegen = report.Attribution.HealthByKind.GetValueOrDefault("Regen"); + if (report.Attribution.RegenStableSpawnBaselineAttributed != 0) + { + throw new InvalidOperationException( + "Stable spawn Regen baseline was attributed to an item or skill: " + + report.Attribution.RegenStableSpawnBaselineAttributed + ); + } + var sourceAttributableRegen = + totals.RegenRealized - report.Attribution.RegenStableSpawnBaselineRealized; + if (attributedRegen * 100 < sourceAttributableRegen * 94) + { + throw new InvalidOperationException( + "Regen source-attributable realized-impact coverage regressed below 94%: " + + $"{attributedRegen}/{sourceAttributableRegen}." + ); + } +} + +static void RequireTerminalMeasurement( + string name, + long raw, + long rawOnDeathFrames, + long effectiveOnDeathFrames, + long measured +) +{ + var expected = raw - rawOnDeathFrames + effectiveOnDeathFrames; + if (measured != expected) + { + throw new InvalidOperationException( + $"{name} terminal measurement did not reconcile: " + + $"raw={raw} death_raw={rawOnDeathFrames} " + + $"death_effective={effectiveOnDeathFrames} measured={measured}." + ); + } +} + +static void RequireConservation(string name, long attributed, long unknown, long measured) +{ + if (attributed < 0 || unknown < 0 || attributed + unknown != measured) + { + throw new InvalidOperationException( + $"{name} attribution did not conserve its measured ledger: " + + $"attributed={attributed} unknown={unknown} measured={measured}." + ); + } +} + +internal sealed record BoundarySample( + string BattleId, + int Frames, + int SpawnedCards, + int PeriodicStats, + int PeriodicAdjustments +); + +internal sealed record CorpusLoadResult( + string SourceKind, + int SourceCount, + List Replays, + List InvalidPayloads, + IReadOnlyList SourceArtifacts +); + +internal sealed record CorpusSourceArtifact(string Name, string? Sha256); diff --git a/tests/PeriodicEffectAttribution.Corpus/evidence/README.md b/tests/PeriodicEffectAttribution.Corpus/evidence/README.md new file mode 100644 index 00000000..be12fdb2 --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/evidence/README.md @@ -0,0 +1,25 @@ +# Periodic attribution evidence + +The JSON files in this directory are deterministic, generated evidence snapshots. They retain the +minimum information needed to compare a future attribution model with an earlier one without +committing the full per-frame report: + +- model and corpus schema versions; +- exact source-artifact names and SHA-256 hashes; +- sorted battle IDs and the complete-report SHA-256; +- raw adjustment inventories and rule diagnostics; +- terminal raw/effective/overkill and order-sensitivity measurements; +- measured, attributed, proof-class, and Unknown totals. + +Do not edit a snapshot by hand. Regenerate one from the pinned corpus with: + +```bash +BPP_BUNDLE_CORPUS_LIMIT=100 \ +BPP_PERIODIC_EVIDENCE_PATH="tests/PeriodicEffectAttribution.Corpus/evidence/.json" \ +dotnet run --project tests/PeriodicEffectAttribution.Corpus/PeriodicEffectAttribution.Corpus.csproj \ + -- +``` + +The full report is intentionally kept outside Git because it contains every periodic frame and is +roughly 70 MB for the production sample. The evidence snapshot identifies that report and its exact +input corpus cryptographically. diff --git a/tests/PeriodicEffectAttribution.Corpus/evidence/periodic-impact-v5-production-2026-08-04.json b/tests/PeriodicEffectAttribution.Corpus/evidence/periodic-impact-v5-production-2026-08-04.json new file mode 100644 index 00000000..162997b2 --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/evidence/periodic-impact-v5-production-2026-08-04.json @@ -0,0 +1,1856 @@ +{ + "evidenceSchemaVersion": 1, + "corpusSchemaVersion": 4, + "modelVersion": "periodic-impact-v5", + "sourceKind": "run_bundles", + "sourceCount": 100, + "sourceArtifacts": [ + { + "name": "031fe27e87032271f12c6fcc34dfe835ba36469cda9d73e3619157d74bb3df60.mpack.gz", + "sha256": "031fe27e87032271f12c6fcc34dfe835ba36469cda9d73e3619157d74bb3df60" + }, + { + "name": "0428ebb0c3da06dfe2c651e586132dff7c2a52195f58d86b6afb5496a45b69f8.mpack.gz", + "sha256": "0428ebb0c3da06dfe2c651e586132dff7c2a52195f58d86b6afb5496a45b69f8" + }, + { + "name": "05300afab726ab33ce51565796d43d47428c2efc736d88017c74ea196d522a43.mpack.gz", + "sha256": "05300afab726ab33ce51565796d43d47428c2efc736d88017c74ea196d522a43" + }, + { + "name": "07687b20194413b649fee079114a71d99bbdf86ef0ffda9cdff1f7598a176780.mpack.gz", + "sha256": "07687b20194413b649fee079114a71d99bbdf86ef0ffda9cdff1f7598a176780" + }, + { + "name": "0f54b40e6a61c98035e841fa9da799bd3d1a07b776a985a41b9824b5e3eb6cd0.mpack.gz", + "sha256": "0f54b40e6a61c98035e841fa9da799bd3d1a07b776a985a41b9824b5e3eb6cd0" + }, + { + "name": "134e79569681f9fdabf892f2ec34b5db7ea1a21c90c225c675981dc6a7eab3a8.mpack.gz", + "sha256": "134e79569681f9fdabf892f2ec34b5db7ea1a21c90c225c675981dc6a7eab3a8" + }, + { + "name": "150dc0b327246c50656e92503af692d86a7eb5e72eebbb267d1173d63adc2371.mpack.gz", + "sha256": "150dc0b327246c50656e92503af692d86a7eb5e72eebbb267d1173d63adc2371" + }, + { + "name": "18d61d52132b798393aeda0a1265702fd7f701b4259c482e72ae6960eca4d57f.mpack.gz", + "sha256": "18d61d52132b798393aeda0a1265702fd7f701b4259c482e72ae6960eca4d57f" + }, + { + "name": "192d747959f5fc97c9846b576f4ed97fdf0a8aed4f8e2c6a754720af8269ab2f.mpack.gz", + "sha256": "192d747959f5fc97c9846b576f4ed97fdf0a8aed4f8e2c6a754720af8269ab2f" + }, + { + "name": "22de07f79003fc1baaaad346b960880bafbf98a751ec55371b44203f3a5f1a8c.mpack.gz", + "sha256": "22de07f79003fc1baaaad346b960880bafbf98a751ec55371b44203f3a5f1a8c" + }, + { + "name": "259e901ea14b60445b260fc07c29fab4c66d4f5c03ee1d839f0a8b3ebd5e1def.mpack.gz", + "sha256": "259e901ea14b60445b260fc07c29fab4c66d4f5c03ee1d839f0a8b3ebd5e1def" + }, + { + "name": "262f477fee7e4cdcbc3915cf7e80ee07a423fe59398c938f00c0c1494ec0e369.mpack.gz", + "sha256": "262f477fee7e4cdcbc3915cf7e80ee07a423fe59398c938f00c0c1494ec0e369" + }, + { + "name": "2696a7cd990a0eb23485cf2449ba82eb4ad9338295a98ab2c184fd71e4b38f75.mpack.gz", + "sha256": "2696a7cd990a0eb23485cf2449ba82eb4ad9338295a98ab2c184fd71e4b38f75" + }, + { + "name": "26c2b07a665880c80a7357f88917338d72e5a517b340904b127f879ea4bd65f7.mpack.gz", + "sha256": "26c2b07a665880c80a7357f88917338d72e5a517b340904b127f879ea4bd65f7" + }, + { + "name": "29a409df70d1c637b39b28f068ab91a3e7ce060c38f8a8f094cb91f37c554a3d.mpack.gz", + "sha256": "29a409df70d1c637b39b28f068ab91a3e7ce060c38f8a8f094cb91f37c554a3d" + }, + { + "name": "2aba7d6a8209929a164267f91e2203a00194fd1a1d92f6a0146bd99e8bbf82de.mpack.gz", + "sha256": "2aba7d6a8209929a164267f91e2203a00194fd1a1d92f6a0146bd99e8bbf82de" + }, + { + "name": "2ff97dda4d4521ec25e62b7e249e295cabbda9fa3af314dc404c424cd80fdbda.mpack.gz", + "sha256": "2ff97dda4d4521ec25e62b7e249e295cabbda9fa3af314dc404c424cd80fdbda" + }, + { + "name": "36c1143c1ad2098a8a964bf3f5e636f3b99603df117f4747292e58f126a4ecc1.mpack.gz", + "sha256": "36c1143c1ad2098a8a964bf3f5e636f3b99603df117f4747292e58f126a4ecc1" + }, + { + "name": "39e9544fb18713f145c1f7379710078188bacb76f22b1115b0f5c727c7063c20.mpack.gz", + "sha256": "39e9544fb18713f145c1f7379710078188bacb76f22b1115b0f5c727c7063c20" + }, + { + "name": "3fbdcb44d50925f74ff70208bc23572f2b9c6d0d1496920ff39985c2f40d5970.mpack.gz", + "sha256": "3fbdcb44d50925f74ff70208bc23572f2b9c6d0d1496920ff39985c2f40d5970" + }, + { + "name": "401d599953c7f2a975f5dfe4757647aecb852cb57396df6af1b57dfa200d08e9.mpack.gz", + "sha256": "401d599953c7f2a975f5dfe4757647aecb852cb57396df6af1b57dfa200d08e9" + }, + { + "name": "40db78bbdfb59f4aef31decb3a704c9517db51084abb59e1fedb002569242215.mpack.gz", + "sha256": "40db78bbdfb59f4aef31decb3a704c9517db51084abb59e1fedb002569242215" + }, + { + "name": "4395770d65d151fa6a57e63194e5125e020ac13b4acd1ada3e0ddace1889b483.mpack.gz", + "sha256": "4395770d65d151fa6a57e63194e5125e020ac13b4acd1ada3e0ddace1889b483" + }, + { + "name": "43a0306e480ec032f170f86a58862fa66896c80db3c6a6be1fbf9fd2ab75be48.mpack.gz", + "sha256": "43a0306e480ec032f170f86a58862fa66896c80db3c6a6be1fbf9fd2ab75be48" + }, + { + "name": "444b8dcc1b6a0c92ab71a87c3ff6fd49c85b74a3be4accb1d6f403901cf77d3c.mpack.gz", + "sha256": "444b8dcc1b6a0c92ab71a87c3ff6fd49c85b74a3be4accb1d6f403901cf77d3c" + }, + { + "name": "452978a2c887a7c31ffd014c8860620df742088528068aa39d5e600d1269fb70.mpack.gz", + "sha256": "452978a2c887a7c31ffd014c8860620df742088528068aa39d5e600d1269fb70" + }, + { + "name": "45b1d6667bdc7b5f979335f96ebf28b960037d9ecabdf50facdcbfebb50fbead.mpack.gz", + "sha256": "45b1d6667bdc7b5f979335f96ebf28b960037d9ecabdf50facdcbfebb50fbead" + }, + { + "name": "4851ac98eec4dd308d2a037a68fc59f90ede253e4ae0b6e68b8c511ac86a5344.mpack.gz", + "sha256": "4851ac98eec4dd308d2a037a68fc59f90ede253e4ae0b6e68b8c511ac86a5344" + }, + { + "name": "497306d67095543c7a99a34b618aaa8bfa6f2fc916280aacac166cc5af5384f2.mpack.gz", + "sha256": "497306d67095543c7a99a34b618aaa8bfa6f2fc916280aacac166cc5af5384f2" + }, + { + "name": "4b60124c0670de6f989938dc4f8f49c3194923fcb564c36ee070faf184fe0844.mpack.gz", + "sha256": "4b60124c0670de6f989938dc4f8f49c3194923fcb564c36ee070faf184fe0844" + }, + { + "name": "4e45e32d49933105b9383f07562d0e4f9b1be039b5d5b84d6b55465955885997.mpack.gz", + "sha256": "4e45e32d49933105b9383f07562d0e4f9b1be039b5d5b84d6b55465955885997" + }, + { + "name": "4f2621d6b92a4a599b65af1efede7087d8b6d41b165825a79dfd39d5056ee698.mpack.gz", + "sha256": "4f2621d6b92a4a599b65af1efede7087d8b6d41b165825a79dfd39d5056ee698" + }, + { + "name": "5000c703e1c2d38d8676640a5293b331f3d64a66b14aa737e2e6994ecdc4d4da.mpack.gz", + "sha256": "5000c703e1c2d38d8676640a5293b331f3d64a66b14aa737e2e6994ecdc4d4da" + }, + { + "name": "53da4148b4573b5c3795bd03b3fa2ce978fcbb4e05940635d3ecc1518632532a.mpack.gz", + "sha256": "53da4148b4573b5c3795bd03b3fa2ce978fcbb4e05940635d3ecc1518632532a" + }, + { + "name": "54aefcd22c0c17fe74bc0fde206ee3775fb511cb69116bca2f167225caeafc79.mpack.gz", + "sha256": "54aefcd22c0c17fe74bc0fde206ee3775fb511cb69116bca2f167225caeafc79" + }, + { + "name": "55e6d3b28fd1ba0d97959144934e15030734a024f8e51da51285ae06cbc6586b.mpack.gz", + "sha256": "55e6d3b28fd1ba0d97959144934e15030734a024f8e51da51285ae06cbc6586b" + }, + { + "name": "577a614cef4a3fc9f2602d5a9b606a2833ecafe88bdf40bab51d2a96529a3537.mpack.gz", + "sha256": "577a614cef4a3fc9f2602d5a9b606a2833ecafe88bdf40bab51d2a96529a3537" + }, + { + "name": "58170117fedd20823368e7f6d47c78b0b5b91bd03c207afd34352433a9dee71c.mpack.gz", + "sha256": "58170117fedd20823368e7f6d47c78b0b5b91bd03c207afd34352433a9dee71c" + }, + { + "name": "58ed42e6f311193130133584892c3a66a3bdc58ac15d934f03268fa269bb4658.mpack.gz", + "sha256": "58ed42e6f311193130133584892c3a66a3bdc58ac15d934f03268fa269bb4658" + }, + { + "name": "59d6b0234fd08a1b7c0442f934185964c6de22a8ad3c5941777febc1e6f7041e.mpack.gz", + "sha256": "59d6b0234fd08a1b7c0442f934185964c6de22a8ad3c5941777febc1e6f7041e" + }, + { + "name": "621e37b94d6d42847bfb3a95df7cf01b06b792f0962c99a494a0b728a6796797.mpack.gz", + "sha256": "621e37b94d6d42847bfb3a95df7cf01b06b792f0962c99a494a0b728a6796797" + }, + { + "name": "658fcc8c2f08be95207c739b21e611b7557c05db545a74a6995740ae84d49ee4.mpack.gz", + "sha256": "658fcc8c2f08be95207c739b21e611b7557c05db545a74a6995740ae84d49ee4" + }, + { + "name": "6ab4261e8a1258dbe4cf3b7b0ae6dfcf8992b8489d6f3e289547e2fa2182815a.mpack.gz", + "sha256": "6ab4261e8a1258dbe4cf3b7b0ae6dfcf8992b8489d6f3e289547e2fa2182815a" + }, + { + "name": "6b1cdf5163cea201c90cb7d1c25244f0444314354a7f082cf5cc0d024deee878.mpack.gz", + "sha256": "6b1cdf5163cea201c90cb7d1c25244f0444314354a7f082cf5cc0d024deee878" + }, + { + "name": "6db41cb654608c3d5320d8b512e22d4b6a53b2f62ddd4ed67507ad21a1133317.mpack.gz", + "sha256": "6db41cb654608c3d5320d8b512e22d4b6a53b2f62ddd4ed67507ad21a1133317" + }, + { + "name": "72c7ccd223c8569773aea980d4e81cab00dbfdb911d3af6393b4c70e924edccb.mpack.gz", + "sha256": "72c7ccd223c8569773aea980d4e81cab00dbfdb911d3af6393b4c70e924edccb" + }, + { + "name": "76939bbaff12c97a5d926a03a760621b7ce94711fe6495e116824e5bda3f87fb.mpack.gz", + "sha256": "76939bbaff12c97a5d926a03a760621b7ce94711fe6495e116824e5bda3f87fb" + }, + { + "name": "785f52c01db948cfeb9214f2f2a863b77e1e7f5cadeaa43f9cd6558b4a83b552.mpack.gz", + "sha256": "785f52c01db948cfeb9214f2f2a863b77e1e7f5cadeaa43f9cd6558b4a83b552" + }, + { + "name": "7d5665f35b07ec3cee445a50a75b5a13abeb1d0e9e2341e6729c5ba720c8c2a6.mpack.gz", + "sha256": "7d5665f35b07ec3cee445a50a75b5a13abeb1d0e9e2341e6729c5ba720c8c2a6" + }, + { + "name": "7e53e30e2fe7b59b57fd8ca645844efe4a395c6a6b8955db81faa6c7cb81b0fa.mpack.gz", + "sha256": "7e53e30e2fe7b59b57fd8ca645844efe4a395c6a6b8955db81faa6c7cb81b0fa" + }, + { + "name": "7ff92985b8e998a95e3ffb9d5705306810c8c490b991fa648f733f39e777b892.mpack.gz", + "sha256": "7ff92985b8e998a95e3ffb9d5705306810c8c490b991fa648f733f39e777b892" + }, + { + "name": "80b66b9c93dc391c45b1f771a2b23b8758b3d134996d2893a8047a1bf3f12d08.mpack.gz", + "sha256": "80b66b9c93dc391c45b1f771a2b23b8758b3d134996d2893a8047a1bf3f12d08" + }, + { + "name": "819ef08a1f63f9c257222f9b7f106e7ad5abbc5681785cd58458701d09873aba.mpack.gz", + "sha256": "819ef08a1f63f9c257222f9b7f106e7ad5abbc5681785cd58458701d09873aba" + }, + { + "name": "8644ad09756ab84bf4cf8f35ad6a4ff9a7bb0b4b3a6458b22bd0981f1472becc.mpack.gz", + "sha256": "8644ad09756ab84bf4cf8f35ad6a4ff9a7bb0b4b3a6458b22bd0981f1472becc" + }, + { + "name": "8804134abaec6f8a38221334e3a957f9a602c3a7a1c550e3759b723b5277808f.mpack.gz", + "sha256": "8804134abaec6f8a38221334e3a957f9a602c3a7a1c550e3759b723b5277808f" + }, + { + "name": "88bc4f79c6cfc379675b03e7857af055065e4eb0a9bc111474afbc0160541084.mpack.gz", + "sha256": "88bc4f79c6cfc379675b03e7857af055065e4eb0a9bc111474afbc0160541084" + }, + { + "name": "8b00fe7f436cbc716fffc31c9188900e32510308d8468cf9384b99c05ed1df38.mpack.gz", + "sha256": "8b00fe7f436cbc716fffc31c9188900e32510308d8468cf9384b99c05ed1df38" + }, + { + "name": "8c75ddbff78ca333a5f4c75dad39c45f1a597ff66cb6bb3917bc2df8ef80653a.mpack.gz", + "sha256": "8c75ddbff78ca333a5f4c75dad39c45f1a597ff66cb6bb3917bc2df8ef80653a" + }, + { + "name": "93946489c4f4f0ef399a4a2970f8b456fdaa8a85d480ff0aff306353a6e47723.mpack.gz", + "sha256": "93946489c4f4f0ef399a4a2970f8b456fdaa8a85d480ff0aff306353a6e47723" + }, + { + "name": "964ab0b26a4b32cee769cea28cdf7be2b20348c6625bc2dffc6a20fc83f87051.mpack.gz", + "sha256": "964ab0b26a4b32cee769cea28cdf7be2b20348c6625bc2dffc6a20fc83f87051" + }, + { + "name": "964e114342fe58bb90dbf4d5193ce3d983ad38e3185f4e54ce3beb4cf903f9e5.mpack.gz", + "sha256": "964e114342fe58bb90dbf4d5193ce3d983ad38e3185f4e54ce3beb4cf903f9e5" + }, + { + "name": "9776ccbb12bf032de9f638584c7286d1b188b4c80f8dc5637fcfcef2cb9c21fd.mpack.gz", + "sha256": "9776ccbb12bf032de9f638584c7286d1b188b4c80f8dc5637fcfcef2cb9c21fd" + }, + { + "name": "99c5833f61bdf37636c85f593edf2499b1b54d0c5d35609d401904bced100226.mpack.gz", + "sha256": "99c5833f61bdf37636c85f593edf2499b1b54d0c5d35609d401904bced100226" + }, + { + "name": "9dbc14e682afb5bb2f94377d13ccfe6b164c925062ac960cc55c673b9b768f77.mpack.gz", + "sha256": "9dbc14e682afb5bb2f94377d13ccfe6b164c925062ac960cc55c673b9b768f77" + }, + { + "name": "9ec68f1d03be4364f15e5156c8af5df3f07146be7ff5e49d2595484a47e98a8e.mpack.gz", + "sha256": "9ec68f1d03be4364f15e5156c8af5df3f07146be7ff5e49d2595484a47e98a8e" + }, + { + "name": "a059e5be5f2d7f1ce575f874f415a9ab5e7b342623626f6ffa199df1b28166d6.mpack.gz", + "sha256": "a059e5be5f2d7f1ce575f874f415a9ab5e7b342623626f6ffa199df1b28166d6" + }, + { + "name": "a09dac2f35b698812272b9504c0eec5e582570b3c1964a6a51d59038f7b16e19.mpack.gz", + "sha256": "a09dac2f35b698812272b9504c0eec5e582570b3c1964a6a51d59038f7b16e19" + }, + { + "name": "a3d2fcd0aea54c090e7a26d3a0525f4eb8a7a10c842eaf87ad702d82caad9e4f.mpack.gz", + "sha256": "a3d2fcd0aea54c090e7a26d3a0525f4eb8a7a10c842eaf87ad702d82caad9e4f" + }, + { + "name": "a812d9e8063ee6d3e29e2ed9bc0a88e2d8683e231912133a424241c6a3564d37.mpack.gz", + "sha256": "a812d9e8063ee6d3e29e2ed9bc0a88e2d8683e231912133a424241c6a3564d37" + }, + { + "name": "a8742b2bb8b3e5640f0e47a11479d1e003a79f5ea3c7ad802b15ad42b7b1b799.mpack.gz", + "sha256": "a8742b2bb8b3e5640f0e47a11479d1e003a79f5ea3c7ad802b15ad42b7b1b799" + }, + { + "name": "a88effad775a95e727d906c69c4bb0126f569678ffb6897a8ce1b8a36e2b91f7.mpack.gz", + "sha256": "a88effad775a95e727d906c69c4bb0126f569678ffb6897a8ce1b8a36e2b91f7" + }, + { + "name": "ab2b7e7160e5836d5a9c0e97718e15ab3f828f11fdc4d5938fb1e49a7a1756a5.mpack.gz", + "sha256": "ab2b7e7160e5836d5a9c0e97718e15ab3f828f11fdc4d5938fb1e49a7a1756a5" + }, + { + "name": "ad98cc551da9941544c3a79be4f143c4413c3c5564c4c2c80ae7dce130692302.mpack.gz", + "sha256": "ad98cc551da9941544c3a79be4f143c4413c3c5564c4c2c80ae7dce130692302" + }, + { + "name": "b46fffb9df7457765f370d05590732f296541e8884febf1d19c83146981335e0.mpack.gz", + "sha256": "b46fffb9df7457765f370d05590732f296541e8884febf1d19c83146981335e0" + }, + { + "name": "b59ff36cf2bf65530c2f33faa7ca62dcdae9c8372d3aed9718138c92256cc10d.mpack.gz", + "sha256": "b59ff36cf2bf65530c2f33faa7ca62dcdae9c8372d3aed9718138c92256cc10d" + }, + { + "name": "b61cac63c8e4987a266af873febbb417cf6bec08907ae15b11f54d6ba2f800fd.mpack.gz", + "sha256": "b61cac63c8e4987a266af873febbb417cf6bec08907ae15b11f54d6ba2f800fd" + }, + { + "name": "b681d8a290dd5f51d3ab58d3bbbed428497ee9b107498f0c0f9620349cfcb5d2.mpack.gz", + "sha256": "b681d8a290dd5f51d3ab58d3bbbed428497ee9b107498f0c0f9620349cfcb5d2" + }, + { + "name": "b77bce130551dd22b5f49c090ba19bab3ec041298e94af778928b85279b560c1.mpack.gz", + "sha256": "b77bce130551dd22b5f49c090ba19bab3ec041298e94af778928b85279b560c1" + }, + { + "name": "b9fa3bb93f9b7ba718a3b40b373480931c29beaa13e5fe7927c36e84edbe7fce.mpack.gz", + "sha256": "b9fa3bb93f9b7ba718a3b40b373480931c29beaa13e5fe7927c36e84edbe7fce" + }, + { + "name": "bc73cab5410b43e704d35ab710f917cc5b10c421d7619d21b7f0be9bdb4204b1.mpack.gz", + "sha256": "bc73cab5410b43e704d35ab710f917cc5b10c421d7619d21b7f0be9bdb4204b1" + }, + { + "name": "c0b86de049eed12a499f9acf3960d798fcac8b452da7e3a36b8718309e2417ab.mpack.gz", + "sha256": "c0b86de049eed12a499f9acf3960d798fcac8b452da7e3a36b8718309e2417ab" + }, + { + "name": "c502ebef150061b3c271bc1cc759fe34e9e07b9e3babccae6089a4472bbbf01f.mpack.gz", + "sha256": "c502ebef150061b3c271bc1cc759fe34e9e07b9e3babccae6089a4472bbbf01f" + }, + { + "name": "c55a81f3b9489bc82d9f74a373100a4fa38d7c314d13606e68eaa5333f5b0f92.mpack.gz", + "sha256": "c55a81f3b9489bc82d9f74a373100a4fa38d7c314d13606e68eaa5333f5b0f92" + }, + { + "name": "c66933d1421734879b0d0f97941ac8c9ae7c68ac033691bf93322f58083df649.mpack.gz", + "sha256": "c66933d1421734879b0d0f97941ac8c9ae7c68ac033691bf93322f58083df649" + }, + { + "name": "c8344f44279e11deff162f3132932027188d4feafdfda39326dcb00ed9155f0f.mpack.gz", + "sha256": "c8344f44279e11deff162f3132932027188d4feafdfda39326dcb00ed9155f0f" + }, + { + "name": "c89611779c12805b9263836ccb5ef62d67539929f3abd6d91149a37854cb24ac.mpack.gz", + "sha256": "c89611779c12805b9263836ccb5ef62d67539929f3abd6d91149a37854cb24ac" + }, + { + "name": "cbf58f19aaf05ffe9a2078d13cc4120239ff559201819a3a1036e6eb49b332f4.mpack.gz", + "sha256": "cbf58f19aaf05ffe9a2078d13cc4120239ff559201819a3a1036e6eb49b332f4" + }, + { + "name": "cc0b6ad1d111964b9de135526878cab79ac757594104fcabc8a34823919f66c4.mpack.gz", + "sha256": "cc0b6ad1d111964b9de135526878cab79ac757594104fcabc8a34823919f66c4" + }, + { + "name": "d44a13d1956bf9d550ec123eced97efc4b3c21ab64852bc73062921386e8603f.mpack.gz", + "sha256": "d44a13d1956bf9d550ec123eced97efc4b3c21ab64852bc73062921386e8603f" + }, + { + "name": "d6abd55b1028f41e11ecd26652eaed6045a660698b5009cc9a61cc334dc0c558.mpack.gz", + "sha256": "d6abd55b1028f41e11ecd26652eaed6045a660698b5009cc9a61cc334dc0c558" + }, + { + "name": "d9895f3b288debe365f8dbc93e84d04ece7a98e097414bef2dcc398f81a7eb3e.mpack.gz", + "sha256": "d9895f3b288debe365f8dbc93e84d04ece7a98e097414bef2dcc398f81a7eb3e" + }, + { + "name": "e2fc3aa9cdc17939bd346915ddf2695faf801827bad4e36436bf210d8d3c3baa.mpack.gz", + "sha256": "e2fc3aa9cdc17939bd346915ddf2695faf801827bad4e36436bf210d8d3c3baa" + }, + { + "name": "e8e98c2eacbd5cbd0538cd61a4e852edd7faaba6ce7457ee444f7d3fc17d5d15.mpack.gz", + "sha256": "e8e98c2eacbd5cbd0538cd61a4e852edd7faaba6ce7457ee444f7d3fc17d5d15" + }, + { + "name": "ead723cd3563643665e4639cde301e592e56b98820ca2ec7333d2705cf3264cd.mpack.gz", + "sha256": "ead723cd3563643665e4639cde301e592e56b98820ca2ec7333d2705cf3264cd" + }, + { + "name": "ef1a4208dc4088d47eb7bd1514284b2634f2f25f360e698af1d04e3223ca2906.mpack.gz", + "sha256": "ef1a4208dc4088d47eb7bd1514284b2634f2f25f360e698af1d04e3223ca2906" + }, + { + "name": "f09cc673597232a0b5decebb525a07518fcc57e6b013d720376ffcbd10c5bc43.mpack.gz", + "sha256": "f09cc673597232a0b5decebb525a07518fcc57e6b013d720376ffcbd10c5bc43" + }, + { + "name": "f24288c51cbc6eb623df0daa99752004a55e23b5cddb11d9b660ad11a089e4a7.mpack.gz", + "sha256": "f24288c51cbc6eb623df0daa99752004a55e23b5cddb11d9b660ad11a089e4a7" + }, + { + "name": "fac7ef3eab9d069dfb61539ff8531c8d024f391dc4b3a430aa9cbfcee8ec22a4.mpack.gz", + "sha256": "fac7ef3eab9d069dfb61539ff8531c8d024f391dc4b3a430aa9cbfcee8ec22a4" + }, + { + "name": "fd36a8a3ffa1eb432b80c83874f49874452d5ff3bfeb3ee4815a8bc5e50a5a9f.mpack.gz", + "sha256": "fd36a8a3ffa1eb432b80c83874f49874452d5ff3bfeb3ee4815a8bc5e50a5a9f" + }, + { + "name": "ffa1461246f4eea5ddc89d2b17a332f592455dd30a9c9484e9d7c2b2f9da7657.mpack.gz", + "sha256": "ffa1461246f4eea5ddc89d2b17a332f592455dd30a9c9484e9d7c2b2f9da7657" + } + ], + "battleIds": [ + "0058461536694c0a8d32026d7c8eb86c", + "00a5d66b1e3a4e2ea859d923802a5b60", + "00aa996be1e94604897e54c3ddfb0b42", + "00b3e1dc8c384079b36a8ef786a03508", + "00cac3dbc0cb4fb485addef70fae2ada", + "00d209f3dd634de197ccab997392d373", + "0158c91c38cc4ccba1ebd18db95836b6", + "01c2c10032224ee59f45c52d16b3098e", + "02388a7108d4470ebae9633c006eb2e8", + "0291b9db652145c28bcea6dd277ab8d5", + "032c8f46241c440697b1bad77097a5ef", + "034774f6fcc74051bc92c94bdde5f0d6", + "034de0051c0c4de1a579bb1cd3230765", + "0370abd93c8649b2b47deaeb2e30f57a", + "037ae93801ba407183743f992fed5e83", + "03a4c643b2b445e5b08ec6266c0017ad", + "04c5556626d341f3a49c60ba5722a2d3", + "050028e25c81463d9226f9aa27009850", + "051572e8946f47978929a5c5f1e0a986", + "05470568407645578d93f75ccffac9a4", + "05c9561ac8d64f33b4a81b377e53dd77", + "05fbe509e7dc4365b83080b5cd52bd74", + "064fd2a36e4d47d39303a3cad1c5a992", + "069917dd4c2d4d3d99a05b729936face", + "06f75640ff7342ea9d4031eed2536b8d", + "07160909ce3c44728249bdc8f31a0792", + "0737e8264c00452f8d810156683f13c8", + "07b82b7beee443ff87d75764ad198484", + "07c2042e11e8460fa654b2448da43e00", + "07d52075273648f8914c4fcc664a5eb4", + "085070a446454200835572a0cea2eaca", + "08fbb96a6cc04285be132c894ed336d2", + "09cb4e941c654827bd34ad85f894b6d0", + "0a2b771219014d38bd888cec0f67ec65", + "0a94a45835a747aa9f5c5c76a51127c9", + "0aae25ae3cc34d2aa9e7cdf2e30c60b3", + "0ab10593c0b84be891f894f58dd69c09", + "0b1a1a51e9aa40478d5638b958383261", + "0b32ac7202494071aba73bced027f844", + "0b53897177474cb7bdb3359dc57a5d86", + "0baedfdfac1b491891bc82899d1c46fb", + "0bc7f6e0d1554f4eacfbff978600a45f", + "0bd6ae8a491549989ef5463444f36c27", + "0be1e15a35fa46b0b7ac9c256c25f80b", + "0c0eed8a0ca74f5a8a64ea1a1685e903", + "0c6a8014986d426593a5685c0341ce82", + "0d405d496bd141e1980e52e6b5469f33", + "0d9dc368baa54296a2168cdce99f5436", + "0dac4fa927544801b617d04ac5b681dd", + "0e8639c8a95e432c91ce5470012b4036", + "0ee84ad368b44812afe2319bfad182bd", + "0f63f51c2ab8499daebdd7b5fcde330b", + "0f90406e78954d6dad5b5457cc976000", + "0fc40e6426184daa8840100f3a192918", + "0feceb66ebdc4858858be0d8a0aca483", + "0ff3ad18f3d447bea9b948580beac6f4", + "1005c0ae43eb4d09a4085973946c3e1b", + "10116385e5854e11b4a5e6ba03351a4c", + "101f772c1e8040da8c8ea616497623b3", + "1024fc69f27b4b838c8968c72a81e7d1", + "102efdd8c9ef49c5aff250fdb48730c1", + "10843ac3b5064c3a8db3034d36aaec9f", + "10ae37e2c7dc4923a579e630c359e10f", + "10c2673a491747cc80c709bf17ae29b8", + "10eaf7ce8f2d4c24865d685261f1d614", + "10f8f6b98cac4e26a23cc618cb6fe523", + "111b42f13cf74dc7b857c7f7422c7bf1", + "1149595735a043a9a99055ccf3e188ce", + "114b23e7b88c465bb19dc04ae3c80eb8", + "11e0bfbb28fb46b28d1518dca3ffd170", + "123a04ea10fe4a19bce92d635907c849", + "12599948ac024871a813bdb4beb8f655", + "12fa8fe3c23848bf805bdb7ae6ff4da6", + "1336463d9ccb48b1988c74eae23120ce", + "137789db84b544b08d28e1f7c8632ff5", + "1388a5fa78e64db59e165c68e8437e33", + "13ec55eb40334010bd4c6935b00cf28d", + "143ded3b7e5446079f525c5f0366361f", + "144a12fd7eac4132afaa1b5619252a7f", + "1586cbe600044dd5bf556cfe627e8514", + "15a0fc0db2f246368ef09ad1bb959caa", + "16ec2c2334674ce1a18612692d65fa93", + "1738ab787b4a4f7d9c7a0901b1e7f59c", + "178966d97dd94506b725a2ef6629e6dc", + "179142a9364c4cc79b1ee3a95cc5f256", + "17a1d1c27646436d89e40d064430148a", + "17c27ce0f4c94bb98c2322d2e9dfd873", + "17c9c61db0ea424fae5cd7c9841f364e", + "180ae04a0a1046a187eb7caf9994af15", + "181c991a815c4343882db045cb38f460", + "184bf8ecbff345379878a2c33da53e28", + "1852bd25ee2947aeb8643c78195ed7ea", + "187a3d63ae754c5b992747475c10ebf0", + "189c21699ba24c7f9232039b232789c8", + "189d9eca60a64d5aba3fdf9ac1cbdc3a", + "18fb232b42ff43978ae5e76e6191b90e", + "192ea806b6a54776bb5d251e07279ff5", + "19ab873b9f8e48a2b8bee75a8f51dac5", + "19e682e07ba940c4ad6bf4020c9611cf", + "19ee740385224ed5973c56a2d093b244", + "1b3c47892a6a4c159e8177a77fbca849", + "1b431de4f3a44c05b86075731f554e12", + "1bb1ec183610470bb39fc92ca294fcc4", + "1bc2167f94eb46f89c495b211f1ca6bd", + "1c17b2f70d01466fab53b36b4a1dee56", + "1c7111a347ae4e9b8849137dff02a7ec", + "1c7463b3fd974e6caa7738fd655dd67c", + "1cbbfeafe529449e9439d42cf6a4f6a5", + "1cc6b0ac2b39467a8236732844ad6f00", + "1d080b2130634a53b5bb4cddba0b5e68", + "1d2afec29bd540fa9ec97273d8290ff8", + "1d31b40ca23e4befad9330af3ce55e7f", + "1d8c704a59824ba7953611acf126b91d", + "1d90e5d43da6416faf8ddf548e6a8e3c", + "1e0c8077e798494083d43a8265864695", + "1e408e66743e48e99baae1b4976af0ec", + "1e68ec841ee444e4be1f64036bf45a6d", + "1e9d34fb91e64272a95409ced5df8b95", + "1e9d5fe826dc495b806daad2a654bc36", + "1ea6f98520414bfeb15899bf5603ea3d", + "1eb3093709b34409b821318cfc9508be", + "1edc19b35bcd474eab1e632343a05086", + "1ee6baabf87845489c61f280f2d32bc0", + "1efa727dc08447409b2c2edc3c5d7ee2", + "1f4ed3a88f0a4466afc575d009da0da9", + "1f79e428877c43898855be6f8a3b6a31", + "2079d8ff134b40838866f0f94cc0aac1", + "208adde332a340c7836bb39bddbf50eb", + "21de7c76fab24e908d689fcd201f19e9", + "21ebb73fd71740b787a615de2493996f", + "227b2911c563440cbdd3219426f57541", + "2284c59842a044fe8aaaf07d532e8040", + "22996e77e8144dcfb69d1834456fa3e6", + "229dea763641434f9a67e5376515869d", + "22c2654b3b7a4869a99c8c0846130836", + "22d3a8426c964dcebb822a8a7edaeee7", + "22e9b7f2d288407fb3426a1863044424", + "23de624c37164ba58c4bd029c0b110df", + "24aae2238a34448e94229d6e0480e712", + "253182e4767c4a89bd8e92e17f070daf", + "2539ffc65a0e4bff8d62602dc2822e05", + "25b3167be5f44d658a42b592800e55a5", + "25ccdb556b374b71a4490ce60d71e7e5", + "25cd0ca8a9894728bfdb56ff4eac27a9", + "2628134d38354e6e92b907ef1fc573e1", + "269dd661b805492d8c39f211f6c913e7", + "26a3ac1f44384e73a445d46ccd7de7f9", + "26a4c3cb7336460a82991ee30b3a79e3", + "26b376f07a0243288eab8a9207e676a8", + "2702739437584063ab066fe3f789284b", + "27497ce08d4f4491a2a77d36bfb6942f", + "279e835b03614df7bf647a50c551376b", + "27bbdf457891487d9e2b68eccf7e509d", + "281ca040f4544361b693c8f6a58fd3a4", + "2821c35b945042778b17e3e3ec6d154f", + "283635196d344c159fa391e0c2aefa2b", + "284b8d38609747bfbe54f65b6a5d69db", + "2875c5ac55f84381a15960372dc7095a", + "287e9bace6a84b40b071a6477572e00f", + "28b2f2562a5b48209d4a9d94327b73e3", + "28f780cb153c4c30a75118b7dde96983", + "29339dff128a445aa8efeae5972188e1", + "29543294d9b3482f83735c9a5cb4fa45", + "295eb1d547844f13b8dc09a5168befc9", + "2991e1205f04409e8c99b4e456ac707d", + "2997e1781e8442a18703e85506036791", + "29c6fe8ea88e4478be6b598d8a5ef6c6", + "29f0d03a4f4d4f93b04221dabc5e1db0", + "2a70b10530984320bccf3ef008fd1cd1", + "2aa87208216441ce82a20604147f26ab", + "2ab58daa7a6748328b1ab5b005a9beb6", + "2adbb2dac6f347f68af306f5e9d2afd9", + "2b0ce45d9aa54d26ba1e3f3617ec227c", + "2c2e94745cae4efa9f3a5ec661c2a097", + "2cb35105af2d4424aa5797a49b2b60b1", + "2d0aadd6cbcc4fda8017d2f5d0dd6912", + "2d1a858af51d48bf93fb14bc4d503a2f", + "2d909e7caf234f12a02d2a180048b481", + "2db92c4f885344818457d332820f895a", + "2dc1b18992c544faaf2d277ad9ebbaab", + "2e2ca125e3d94057b0d8e9ea487d8347", + "2e34f38442004798aab04990a2fdea41", + "2e4dc3e02a8c486da1e0392fcf7cefc6", + "2e9544b2ee5944ae95929ae45aa85ad1", + "2f4251e09c5149a18a9059528a6a8881", + "2f61fb9f9c0142e98998a13ebb6c55d6", + "2f90c17cfb9f41cfbeb43ce044527382", + "2fde0d598a1a442fa998e6b0f40c4d3d", + "2ff03738d4ea4ba69cb60468653a335a", + "302bcca947454b5991854be58c43c9a6", + "304e5643e818498baf8200db30104c4e", + "30bcb5a6d3c847c688f666900fe2c95f", + "3128fb4e35b54f59a3f445d383aa4805", + "316a6943eca14da0bd6185d4a0adf26e", + "316a88b8fd1a441fa7f275d0ffca95a6", + "3219321a0fdf4fe9a09e819a3adcf42c", + "3244a271bbc34cdbade355d497519ea2", + "324ba93ac6d64d8680fe10a784b7cfc8", + "329fe54dd1234073a694a53b0b53e1f7", + "32b134126af244248cfb73766ac90668", + "32c6a113445948d8b4300aecd8173443", + "32c899669f3244d48fe30f66b72873d3", + "34466224b3794c5c9a8c7a60f2846999", + "34495f0604764173a2a9296ca2020507", + "34b68c94497d4d9e8e4cde1357867000", + "34c147d48f16414c8f236def80e04c55", + "3518d7623e0e47568294ffc1ed6c9e7b", + "352ee9e5013045a1aa78fe810d3b8451", + "3533b25afd9e4203867e13f20339a6c2", + "35cad42d2a3b4407b27b026b379aa635", + "364d2e7d10d64e2587c0728e5a7067a1", + "3679cc16bed34dde9242a991c128fe3f", + "370034c383344ef59aaacddca1699939", + "370134b1c2d746bbbadd36717d8fc756", + "37107a57570e47b4ad7649f8db0346ca", + "372bd53fae794188b25b1f374a7ad45e", + "375cf3f8e76145a5b1f36e35dae79bbe", + "3774ebc8c4694ca4974f8688c52874c7", + "3780e18615964203be4f23dfbf1de9eb", + "37a093934ed24526ad39a09ecab50dfc", + "37fc1e580409419094f8bfc4f95e17ce", + "382eb35f093b477eac62ea70f078ec30", + "388b64796cb041bf9bf9f669f7031a1e", + "389b9456b5c042878189b44b733e7790", + "3908a470fd454e208e8788bf96b6096d", + "395e1461eaa24969a8e14593085e3b51", + "399b26640f7b4b79905b303618e41add", + "39a0d62d8727411e87c44db83c1f5eec", + "3a0f02a58de640d8a7720d952a537d43", + "3a2384cf61c94e8c9032d4178e330b68", + "3a38385173ab4eeb81de68b908ae1b6c", + "3a73c8b63baf498397a5fe4db9b285f2", + "3ab277f3bf7c43edba70a1108fe03091", + "3ac1abe1c4374437bd75604df0df85c2", + "3ac2c51dd4a040e9a0b1d7d7ee293888", + "3ad87e56864644b6a5e2588976e99c66", + "3aeaa073b55c40a6ba2fd7801a761e9b", + "3af1bf7623d44d85af1a1cdc6bc753f4", + "3b16b21feb934ebc876ed38a90517f4a", + "3b209a32eab14acaa88c0832c3608d9f", + "3b37f8dec3b944a4a584827cc17d1a05", + "3b4547c99e8a4447ba32f90b193090c2", + "3bbf0238494f41a7a961cf75c29947b5", + "3c2fafe3cd45462fbcc22bc4c74131de", + "3c4734161add46818a557ad60d402617", + "3c4df33cd3614425a9fcd5829ba3f58a", + "3c5f9b0906a54caf84ff569e87ddcf04", + "3cb294feebf1462bb9e4b8e75c6bda11", + "3d393990d0d24d1f9e6eba9b8bd538b8", + "3d419a217af842a6af14123f08cf1762", + "3d882f00a1704df39d986cb70a93b8a2", + "3e1821ae298f44019e31c9eb40780ab4", + "3eda77a1e8b7488fb74d0d43806e5b56", + "3eed63b2092a47e4ae5fd8fff1204200", + "3facacdbe8fe495ca6ef8009733eff29", + "3fbe58ed6301457eafc3878be3fa31fd", + "3fd8527f3d6a444394607b59a8b60513", + "3ff0a381f5a44b4ea461060bcf302bb7", + "405d1563a7bc4e93b9eb7cce50ffb0af", + "406b0b5e3db2421b934eafea5b84bfe5", + "4117622e1cef4651a6a914dc8a31737e", + "4197e6d88d1c46f1b3c02471e5572f65", + "41cdb86025bd4153859a8372c1fdd9d1", + "41eb75e8aea74a0096c1ab757e1b7407", + "420d6c519a314950a61c25db73e3bfa4", + "421913c1f8634b4e9e16f2bb6265e197", + "4232ebbe65334cf3beb4929dd9831af6", + "42b3dec6293b4a78bd767d88d2cdeaca", + "4301caaef6fa46048123d34d3fe9044e", + "435fbd5ede7046ddb501af98576af00a", + "43c180b04bd34306863a3421a8b2cc13", + "445f5a55ed0c4e18b7138ec1cc112a3c", + "44b493177da44cc8b008157ffac40c8e", + "44d22eeaf4884364882414dc1aa345a2", + "44dde028796d44dd82c1656088546fbe", + "44e725e8725149e2aef372416f8c2218", + "455ce35d692c4024bbb661da1c873ba4", + "45b6b4ef5485447c9661195d1507ed55", + "460bdb4ea1574738bd91a759d20f2e77", + "465d41c2ef1647d5a5a4dccd977266f3", + "46e994b537be4a0880159e711da2e311", + "46ed695a2a214697afc86118a79b65d9", + "47593bf43c3d4cf4af8b42309cee7154", + "4791bcc7fa7d4c0fa13e1387325a0f9e", + "47ad6a2c37a049aeabd3b514f92f3206", + "47d7b7fafdb84bdf82d6fa145aef59b1", + "485b9b3c44214745badc1a253fa51feb", + "48a6397b40494b1b898337b5282ae50f", + "4906abf273f7476aa73bd9faf9a67711", + "4908f6e00c284da79312d9cbf31e4390", + "4922ca54dae94ea9a560ba2a7d8aeedb", + "493854ddbda4452aad4ab89bd5516fa3", + "49429ed3f0f54f31a5b8d79bf431a255", + "49465ea4612443479cee6f57bd3a14f6", + "495abcec12fd4d62942bffb577818774", + "496bb100aeea4af7939e20a0753398d6", + "498b1e8ac047484a83b5eb94afe5a06b", + "49f6bb3ee5324c23821189a54729d56b", + "4aef03eeb8eb4abe83decd95bb11875f", + "4b0c8fe30d6a437592053261e214514d", + "4b399633dc5840e4b3f2518f0008bc79", + "4c0a1484d803465a8e9d82e62f4b9aec", + "4c1acb47999f429784dde34c7a1001e2", + "4c63fa9796294bc89a594fa85c0b820b", + "4ca7de721819440e85d62eed9dad6bac", + "4d1d98e0491c484f8c6c173248ed12cf", + "4d2b4571dff549e6a2ee55299b2439fc", + "4d52c948ad824f02a2c0199e0a312e13", + "4e0e919086724ef485040f4a0f84a870", + "4e987aab50704461b0ae283b6d5efc0e", + "4e9904d4635a4f8493cc4c1eee14aa44", + "4f3e33d27e9a444da6ecb2223ee7ca1e", + "4fc07b257bf041138f935e1b4f698805", + "502e7499c92b415e916b2a6497c83607", + "5032f3a498204078b63e6007f3370123", + "50bc5821ab6146f196f87d92b75cc82b", + "50d666a8092947d7b8fd7c1a5106b1ba", + "50dfca2fea9c42698cb3f3ca26446383", + "5124d7dc7b6746219251ea30cb4dc249", + "514be3c5b94e4b61a1502236298438bb", + "518257619c86454d87b57e592ad982a5", + "5187b4cca0f0494f90a395cf07c7af70", + "51f244383b1d4373b6c60f74484571e3", + "51f4e45d823846c9a3cddbdc25ec3838", + "52f5da990261406582eb95507405450e", + "530e676257a94bcd911d83d8e6fbcb0d", + "538beeae916546fea1dbfe69c9fce2d2", + "540c7d8bee8844b3a07da33f3c536d03", + "54267caf8ee34238a06744035757df58", + "54321522124646a88c41232da5bb1479", + "545fefda9b81412d808b6486c375cc9f", + "54f0204685404a4b8888eba7bc64fe45", + "551c131659284e1abf762cdeec0592d9", + "56c4ce98b44948109d36f01eeb4f69ab", + "56ec37b5d9e0416eaed19ca5a46bb44b", + "56ed71e7439246a687764844e685591e", + "573a97d014ae426b9b50f1328ebe33b9", + "57d57e28bfa84de8825f9d58d6aa2966", + "58266825a4e84b8f9f73d4492adfe5a2", + "582f60f15e2042e8b2767379032c0dc8", + "5832df578fbe4a5980fd21e454b4f362", + "586029f4f592406dab378444463c0e62", + "58626517342b4edfa326280477efced2", + "58707719126349cb916b35b7ae7522d1", + "587da3d3efc846638f05bade9b5af378", + "58a6b88b309848c29bc585d39c2c6e0e", + "58cbfedabbc34258acdeaa045a89842a", + "58cec05d521d427ab89af8a4db3d9165", + "58ec79473573490bb62da6a8c97d0804", + "58f63e51be30404592c8abb65a1cf8a8", + "59510e5772f748e797a6814cd86dc286", + "597a0695f6d6474f83d83dba8aba2033", + "59eb2364df724cc1bba8d31b7416b12e", + "5a2f4e1eee974c66bde5bced90033639", + "5a4acbff6c02445ab139e5746761390c", + "5a682ff43ad946f6b221818b30b66f96", + "5a70186fde6646aebdf0d51230bf5b99", + "5a80ca2ba05b46aa92bb4be7ed0921ab", + "5b11baac2ed74453a3d16322e989deed", + "5b19eb3d8c0340569925a3f8d2214700", + "5b2d3dc371a54d4cb737672433876b92", + "5b3d0f48c560425999492cef55d87a84", + "5b5ea6b411c1479996dc65d49e610577", + "5b6bc342244545efbe15ee86801c3a69", + "5b7b5d504f9b4afa918158cb14808d6e", + "5b868cb8c6b543ccb53e2a4c3f7078e1", + "5b9e6deddbe24f288a389fe51ea5e8e7", + "5c0d00334da5438b9fc92d3907f48bc7", + "5c1f7185d9564a5aa379794b1c6b8c4b", + "5c23b7ce08424dff841a8a681bd5d581", + "5c45faf0d43e42f9bdddca68b25b9c81", + "5c9488aafc994193bd7c261768f4c5c3", + "5ca7656a237d4c83bc22e2ff9c0fd47d", + "5ced348d77e24bcb968cfa9d08368d6f", + "5d105439972e4abda12ec47923f08b08", + "5da7b3155bab4a8ab46ae88be5b8de0a", + "5dd33d2bb06f472294af1a9663b7f8ce", + "5ddb583db95848cdbffc25eb391b4b03", + "5df247b269d54fbc84b1aa1487ab1b2e", + "5e2f37f552a74e21b5ac1f7399a537a4", + "5e53a114dab046ca91a54f297550ca6f", + "5e5f75b2e6d04ae5b6187d6fd667df32", + "5e78ef097746475e949c5fc524b8e3a7", + "5eb869d8f5fa485590ec6449bf85674c", + "5edf1a5e580944b09a68ab24933d2b0f", + "5eff65278242469a97eaaad1b7a1c65a", + "5f6ccc6b8eb642afb1aa029af6d46d81", + "5f8bfcb6d025413a8d25d820851886aa", + "5fb60cb2c82a44fc9223b09893252dcc", + "5fd19e86fe6e463f97965eefb37528d5", + "5ffdeffaf53c4ad6b40dfb05cf170aa4", + "60af0f9fbe9d428fb8e5961e5e738945", + "611393db6e8141ef8487d1a21a98983e", + "61350d3fcd054420a991b81e665911f0", + "617401ec3ace4d06a961379194038fae", + "61d299863e3d42bbaa7590cc329a07c6", + "61fc93e9fbea4d36bbf248f0d3e8b234", + "620ad35e684242e9ac3b1ec0bbeb347c", + "620b299114ad438fae5b68e1ad75fd23", + "62581a8d62bb47bb84659e1d92247f9c", + "628f951f760c49db92fa0be3bd504a6f", + "629ab46217a8461392c25d7fd6c68e27", + "632e33f3d3a24027a1bd076950ab1d2f", + "63430ce9964f4f6aafa3818e1230f07f", + "6358776483d94558a6b0960c9893dc20", + "6375093fca9e40faab037c2644bedbde", + "63b10c41167a4dc9bc9781328f60d936", + "63b30556d54447a3acfe95b89f82b4fe", + "63b8ca545dc34c63ba9dbfa179c41855", + "63bea2d2705647cc88ce4d071d7360e6", + "63d47d7efa6846f0a3d9efb3b7aa6719", + "63e88ca096234260999dac5fae9e18b9", + "6438f141157e4bcea269f5baa2536d3b", + "6466f0415ab94ba3884cfa11af9d6fe0", + "6476e59810b242268af1a69e4b4f0ad1", + "6489a86a984d4b39acb7825ec69b8bac", + "64a7bbb4a32c4150a34a949d5c4e45f9", + "650b0728e2274fc3840870c3ce30b6f2", + "652875e83c374812aa9c546a22df3e91", + "65381fbfdfd0405fac54f0bf92d6637f", + "657d5a01e0ff4dacac3aee240e8ea804", + "659b082f547e49c7a26936e634432bdd", + "65a2ea1a30f04d1b910becf2ce51df1d", + "65dc74ea3cb34563988c642b8c3d7b43", + "65dfd2b9a3094266857cc7708abde2fa", + "65ecd70cb0b941d6afad8eae69c00d27", + "6679e3250c594d27a17612c27e507946", + "66a154b464124fe397537c4fd02207e9", + "6700ffe6c9c5488ab22ad15ff36b4c9c", + "6812efd81f85478ea490bac1d51bdba2", + "68159fc2a57245dfa9d329b61389d724", + "681a5c1998fb4380841179313d004c96", + "683e06500123484d81a9188d6da202b0", + "68c5629e34444b888917987f8d1e74ce", + "698629a9d25441bdbfc3dca21b15fa81", + "69ae7517ee19437e803b7c34ab77c549", + "69b68385f9e24bb79e3f5a6ba787d15b", + "69c6dcd7ff9242ce962065a0367e47da", + "69df1262fac3438693ea513aaf93431d", + "69f948bd64c0461594e3216a5d93cdde", + "69f9bb4c0f7c452d9f6098c73c0c5caa", + "6a168a216bdb44f28e902d69b2c90610", + "6a4dbff78a564b09b1f6372918dd763f", + "6acfad06e5d24a2eb6d82751ad86bda6", + "6af2f7a5adca4c7dbc21db61ff3b5f52", + "6b1f2b6d0df440e3b114646299883ed8", + "6b6c3e386b334f5caf53386ffc86740e", + "6ba5b5330da546f3ac3e67a568a695f9", + "6bb9343069be44f1ac3d7c2b121e9fea", + "6bc8c16ceb354bfd88f07efa31af15f5", + "6bd9411854974ab88859d26ad37556d1", + "6c43cd5943d14a4a8da7003ebaa69c54", + "6c7a910eaf0041198d3451152e6697bf", + "6d08beaa4d924e50bf433c063a09ed76", + "6d813963c35042df9cf3911354cbabde", + "6d889381d3a344299c07d30fb278f69c", + "6de7860eeffd4e86a8ef1bab237bdb48", + "6defeda2908941ceb3dc48035326fc28", + "6e0b32cdbbd5482582b4d262bf008ec6", + "6e3889fe85bf4d6298c379fcd316c209", + "6e4e264cd33640aab8ff33c560ed927d", + "6e674a614daa42859a31fe3cc8d99c46", + "6e6acf91c68643a29701aa57aab82a62", + "6e91dc8b0deb4710be57c6d4727d0b40", + "6ec78ac93e124bfc8ee195179fdabaf7", + "6eeb5280206742ecaa861952a7e3866f", + "6f08ab70e92442af9912c4de78dc7c97", + "6f6672429f9e4517a2c4f4bd1bb3f7d2", + "6fba200c45844a47aa887bc38e8f98be", + "6ffafb1c50a64103a6e9f6e0f33dde76", + "704a6e9382194278a2aa9c8333e75478", + "705334c04d0249b784818b8b69488d10", + "70e67cc2eef74d2c9a66b1e261c487dc", + "70f40ccff428420092c65d15c86d370b", + "70f622718a994f93940f9869db5e3181", + "70f9a54d5c04408db09a74bddadab7ac", + "70fcb852b4e941da9515def6cc5d1df1", + "7103b50713374cc788e1a4041b99e36d", + "710f2bcc4f03432ba2869aaa0866a390", + "7153fd3a958945e28467b51c49f52d42", + "7163e106a7da4ccd99f4aae89e34968c", + "71b2d418dc4b432d821fe5722975f4ed", + "71b3b53b9b734f4bbdb4a869af8ef0b6", + "722fc27ae81b4761ae0806d4ba4b0d1a", + "7268f326b98d4c4ebd26db986f601db1", + "7286017611b94c5fa5a6d030afbd4cf8", + "728923d8e4e14795ba3dab100a76cc29", + "729525a6ba18456d932bb19f02161810", + "729a89bc54f0415ea6a6ec9888bd4d93", + "729fb62998de48aca19f043e4dc15926", + "72bb405f628e42928b0a8d877ebebf6b", + "72dc04903727457fa58ee2fb5fb86d78", + "72f05cad35fa4be6a6ebf888557dfcf9", + "7335c7110d5b404cbd1164159339bd99", + "7347710049cb4e849a9cb2909566b235", + "734fb9fabc4742bdbf8c9fd69ad4fb6d", + "73c10f94dd624275b28604327795ca38", + "73cfcd309cd54c2bbd89f6ff56fcd93d", + "73f3ab6a133744dbab6ad6d2e2e47a2d", + "7418fc90317e4d5eb3141b1afce10c36", + "7419303fb8354397af94d49607d4c114", + "742590624b17455799ade7dc7816900a", + "744a5d8ff7224e5a92fc34f742ca7170", + "744fe4fbbb524e00935b7717fc0a247e", + "74902b4848064559be3c5e88a1db7ddf", + "74ad63fd6a7e4d928e43539b7afad76f", + "74b5434cbefb43da859f913673425a97", + "74b6f8aec20f4f4bb30359b7176ca80d", + "74d4f118cdf143d59dee516eb4a308f0", + "7532c2b457ce40f19d52f35e48103e6a", + "7591ce2a8a054700a3affaa07095d255", + "75a4e9d20d5244e2a4618e1deab5349a", + "75a679bf96f847459afda3257d7b8ce0", + "7617c6ae063c44949c8b8e7d32c16f03", + "763199de04854e9dbc2a424dbf52cb0f", + "7662835fb9104c9ba9342f0850171b29", + "7662d6ef01504e14b37215f4a3e89598", + "76bb0780eb57440d880fabb2603455f9", + "76ffd9ca5a704464825dbaa38ccbd721", + "7701a47487a64e2caf163fe2bdb0700e", + "7726499076694916b81ffe24fb79caa7", + "7726d69ada1f4e49b9bff590bc96ace3", + "772ca974b68e40b59f7a8aac9f31d72c", + "77505f6f975b408d99d6b7726af9629b", + "775406e8f61b47dea6189b3d84ff7ab5", + "77a96a36f1e04deb86ef7c44b60b9b10", + "781f0e73ae834913a18c32be533eda51", + "7828754e1bec4d93b3393886b965e22a", + "787890be36934e73996a7f01a6f94861", + "78be8ddf599c4f7dacfcb7a61866aea3", + "78e6283e229843cca1fa451a8f7029ed", + "7900ba02eb3a4f66b6080c32659c3e39", + "79019a54869b404ba3145952e339f7e7", + "799d59c579de4d00844b24aeb05d26cc", + "79b684bf7faf443ba89d0856bdffa7d1", + "79cb97d16ef2481a808ba2ff99f5a355", + "7a40263f6ed04c9e8ea14180656199e3", + "7a4af5793e074ac48652661163dc5776", + "7a936e70d5f24a549e397a3837e1ba4e", + "7b15de4c6f4b4888af77670c3ea11869", + "7b1bfd1ce21748d986f3212dc5f17232", + "7b42f6159c7b41d79319938040045381", + "7b58ddb3b36d421289eeb72a89cefb5d", + "7b7429dda11b4a988078cfca21dff292", + "7b90fbbd2cda49008ededdd530712914", + "7c101afddad0459bb4dff2c4488ad078", + "7c57f7a56f5a4029bc9bd0446eb75d74", + "7c69c2d0e0014d2dace902754e080aed", + "7c9a3562c4014d42b6b5e6b95f739b26", + "7d06eb2558cb45d5bf8306cb0759bb9a", + "7dbd2e75ae5f48f897e295d14f43fbc3", + "7df8be4c8c594e6e8e4a02b0d500dc82", + "7e43a31b7f844e76a1841e6c4b53e1bc", + "7e597564f8b245379bfab38c651fe4b8", + "7e9b4294d8fe447d871fb29c94237e35", + "7ea29f3e7f1b49848f43bbf52c76c37e", + "7ed136cc0e3541de959f3dc70aff700e", + "7f5103759fcd4425babf39d933f00120", + "7f6fedf7969f4f7b91dd7e7cd7466639", + "7f7d27f2f9b94d01b9d87325e400567a", + "7fa2b5e5ced549e1aec5d819d6d2f89b", + "7fd262ae7d5d4dbb849fecd60941747b", + "7fdabd43753f4083a47ef530130491a5", + "7fefdf29696b4ecd90ceca0ecd4cf0e1", + "81d5fed5544943f2945b00d4029296e4", + "820a79147a404d2bbc13933563aabf9b", + "8237fd0c02b84bf9bca6f825479f313f", + "8244f5b274c94e3586360049b309a043", + "82b5093102b04d4c97c13de1605f6c26", + "82d97f03630d4123974c8efb257b61b7", + "830c09f450e54f50883471b14e16a532", + "8317e53620c442a6b6beb41e8a9e0855", + "831d2471fa22413aaca1495cb30cde2d", + "838812c78b634fa998ece4cc6f8aa0e7", + "83b86e94af8a4ad8a670105b7e06ec05", + "83c440528f694037be11f965786e2dbb", + "844e30e091924ab58b9c23576c855b91", + "8482c1b552684ee99d0c34f7f7f8d0f0", + "849c5daef11a4734a31098c69908ad75", + "84e7131582044fbd8de1c5fdef38f5a9", + "850bf56c4af443ed9a81327daf0dc438", + "85801358c4f54f96923503782005d724", + "8580f87f07124491a2f96352e26a5a01", + "85c158a5e0a54c54848c225c13986e29", + "860f8d509c3e4309b89646fab1b84e80", + "86304c832c484d4b9655056fb6ae1801", + "864a71caead2489ca3f585338be85b7d", + "867b741fe4ad40ecaeac1d231ebdc8d2", + "8686fb33c1cd4db0b56ca6917f613c27", + "8690499fab8c43a8b48cb48feae28453", + "86e0e8bd77e74fb8bfc482ce128df037", + "86fda9540c1c43b3b73f35303498e39c", + "874888961bc04a02b3ddb67777286a67", + "876924d1050c40a290ad0131e3512df5", + "87d1eba5ba704732a2e88a1b21e9628d", + "87f70cda8bf54645922f6bcd69870a8f", + "8848f672727b4178a4d40f78a4055d1b", + "88f1b57a429645bd92d2856ba56bbc8e", + "890fa2a033fd40b8b967a3ac87663f90", + "894145aa78c24d8ca2ac910cf83c7aac", + "894a2bfd62f64594a2376fd59039b30c", + "89a1556ee12f4700b62517124707be7e", + "8a19de209cac479bbcd975210a089591", + "8a53d3789e12438b9b86369189925a1a", + "8abd8314eb7e44f0ab8334e18bc02a02", + "8b3c50fe5323479883e3c3fd3462fcc9", + "8ba5e02e91ad47ca9ac8c2e2ea705207", + "8ba5fdc94f4b40119a6829a06f0927b3", + "8bad2015b18841c19be2dd1bca311a6d", + "8bde375107214755818909b3063b912f", + "8bf4eba9ca91408c9083bd0bb26467d0", + "8bf74af344f94202b36bad613366d210", + "8d0a86910c5b4e9b95150ac7a5c2cd82", + "8d6097df2caf420083629d5ad5beb3fe", + "8d9b2215710749ae8d28b3b02e3d413e", + "8daefc27b6b548859f0f5cf84aa88274", + "8db4506fbd5a427e9ec120b638253069", + "8dc33f72d84b46069973e9002ef05749", + "8e2e7b5afc564b6fa04c96dd786c83ec", + "8e86c724623d4d88a16e0ec82d0c7241", + "8e9283e3fbec4f65b3670da05e9e28f6", + "8f0b5a745d634036b4869afe9d6ef214", + "8f3268e21a1b42c6bc85823dc821e736", + "8f4f227ddd354cf5a96b566606081ea1", + "8fca81a5a6564f068f4d418a322e9cf7", + "8fcd4b9e5afb414c94a8e0290e0305e6", + "9038f2b3470a4bc0a3ec7d550addaafe", + "9052fa5c275745c0943be965200557e8", + "906766c4732540ae8c293ee975a6f8fa", + "90b4038578ae45d1a68a008b2e5283e7", + "9127a30100654aa3aa5d71017306155e", + "9137173895824268ac52b140a2a0b60e", + "917bf3d735e94729870bfcf9cdbac574", + "9194e843cfae4977afe4c37f7edf8913", + "92027c5c27694541aa4d964fe3a9cf50", + "9210a3c7b6a24a19865dc3a2947cffe0", + "922cbf555e3540a2986a331c7e4b5323", + "924098674331485b97854c28a94d1fe7", + "92647f9008c8492885b2a35bfd369ee1", + "928796a07c26435e8a1dc77b7e95e36e", + "92ab534daa5d4e53af8d2bd6ee69d102", + "92bda0763e0e4207a0689b39da744ec4", + "92f3934131fd4390a6375f8d8e949980", + "933806d2e01741f9802523969d5633a3", + "9355d6aab6134612967a35cf1b8ce14f", + "93d4dcba08e042d4a9e23c54167f96ab", + "945c754f52e54875a911724ad1808932", + "945fc3c74d234ac5be141ce5a502eb42", + "947dd998c1f049028d05f876265cad86", + "94ccaea9c662452480d6482a3e51417f", + "94f5dec39f31460db06bd6500eee37e2", + "9517a5dff30b42018ee1b07fa5eda9b2", + "952c43e739b04104a10d2abb1ed907aa", + "952d716d8531445994bc3e917929bea8", + "9598689cb566408889b10f756252197f", + "95a1f80f924142ccbc386c8841db4316", + "95feda14d90249b5b984249b9323d25d", + "962f6704bb39417cbc75039e51c591cf", + "9637b84cb4da441e87dc893a660041d3", + "96a0666936df43d2ba06e51a0c34f3c6", + "96f238f37c8740d497a60f095089ca5f", + "972fc0bb13ba4ddfb87c74ff0ec03c7c", + "97371756ee97448190ee3aa2605392c2", + "976676e44d4a4a839e878f98b27232ae", + "97bcefcae10d4fbca9cde6a2cac7574c", + "97c600b9118d4751bf160317cab00959", + "985fbe44ea8844539e65929558ca6eac", + "987d1cd4836d4dbc9a873c8a96e5872c", + "988c8e347f794df2810c1da505a97c23", + "98a2c3031a8744309c77d8214ac75acd", + "9920418e9f8b423da9ba5db64337942b", + "99661ac610b54c258281dbf517a69017", + "998273600f034277ae32ea4253921bb3", + "99bf5bbb623d4a9e9d166de75f564ea1", + "99c07d3eff3e44128c1fd2a608e85daa", + "99e6b074c8264ce1b862f4f63597f334", + "99ecdcb6482046dcbc944d97dc7359a7", + "9a8a554c62034a16a11ccb15ff805059", + "9aad2e34f34a414b93378d3adf39f6b7", + "9b37142b8ce24e3ea055aa103900d0c7", + "9b6e92ee44c848c6b9f3c09191463aa0", + "9bd1867f522f4e3eaa1057bf244712cc", + "9c180c575f9b4a6587a92f3cc13c79cf", + "9cbdfbe6eec44b879eb02edbe2ff331b", + "9ce1c2ed92ef415e880f60d63be185c0", + "9cf54b32358246dcb8a0e9f530561359", + "9d6b465feff4412e89cb8d2bf9d9a632", + "9d7a627887f54c3f89d4c8d52a6680e5", + "9df5b310ccc84d1e87ac07dc14e73321", + "9e128246c3d7403ca0115e2a34743c02", + "9e17e933e62f434798487280bdcb32ef", + "9e6f038c14eb42d7b8b538324fc7ec9d", + "9ec7430a814446c1864b814a04af63a7", + "9f606f9f8f0f4b52954303ee543731a2", + "9f79ebbd1fcb419b986176102d2fb88e", + "9f89a56cc08f4eee94a9bf543af28440", + "a0f24ed11367450bb405559088a99fb0", + "a14281737e554a05b3a69e37b74aed85", + "a142fc31861d445fa0d0ce24a51d75ce", + "a1af403ff7a948a2a4c649dcbf84a30c", + "a1b2e4df75c344079518b2560140be4a", + "a1fdcbb566764a1ab5ff4d1c884367e9", + "a28c1756bb224d03bf217e5065c20144", + "a2ab9f8e3fe9493295d8deed66c04673", + "a2be513e77ca4539a92e556a1a8dd2ea", + "a3402ba226ce4b93a83f43222674ab67", + "a344791f0172469eafceada4eb3beb9e", + "a3e07de8dff14e2ea9f88952ceddf008", + "a42d9c8a0dd246cf963a24a7c34197e5", + "a46268a4dfde4c51bb8c62283a675afb", + "a4a4dd986fdd4c78a0c61221a6d23048", + "a4b88518b60f4d84b4e1770853c3f55a", + "a4ca9ce9e007491994d3fe407f96993c", + "a5016b06bea84d6c87b5858bb139f6e9", + "a557671b6302434e817d60d3d4d1f16c", + "a56fa6c7fe01440b906164d0d7d72681", + "a611490e8586420daedd397f78550b68", + "a6757d08111a4e5b833c0c5bb26829d9", + "a68dca6af6ae45ae84749b91a4581bdb", + "a68f9a9e401b462ba46460a94c502e4d", + "a6dffbf16190439fac6a81dc66e0cdeb", + "a77df7c09a6348278d17c09ac8290c90", + "a818d1a0eb0c43d588b0aa2f7e20a3ff", + "a84e0bdbaf2a40859c71372b0eabb45e", + "a898b38fb525466ea18ba2e4d6489de3", + "a8f63f4bab7e4f7aa3b3460fb4497751", + "a90326ecea4b4da3a5dda1160b92b41d", + "a91d2960ed1f4620ad2de76c0e4f6cbe", + "a932049856bc4b008992480b8acb4b3b", + "a97897db615448aaa9aee60fa65787d6", + "a9901fc1fe374e0185bdd2d079512713", + "a9a1fe43f3384d9685b8317b0655558f", + "a9b95a5d279a4b908e664c1e7626f450", + "a9ceec6c598b4180a7b36cb52d127595", + "a9dd174158f5498abcf937b3a2820a49", + "aa58e09a2f354b2abaf502143cfa3a6d", + "aa907fd398294276b3095157bbb06f00", + "aad0d28a610d46dfa7725111023027be", + "ab08a552501e41aaa9be333574f63d74", + "ab0c1b0e99a9455388fb6c523ba5887d", + "ab31f7b3b7514ea089156014f302fd2e", + "ab76245b148b426a9ac5331ab2370133", + "ab7bd4625dff4749b7edcf60bbcdbac2", + "ab8d180b2c0c4ebfabe84be5afde21e3", + "abee805f51c640799a27162046e4c89d", + "ac3dd8dbdc964344bf40bf0b4b751f72", + "ac5d6feb3cd744a09e983a24656e5245", + "ac6135b423b84add9504b9cd7fff52a9", + "ac623ddada464e5ba661704ffc93f51e", + "ac65fd3e1a6e4d6f8588efff268acd93", + "ac9df305c2454bd091533bd41fec56eb", + "acafbe2adc81470795428d6079f7edf3", + "ad2a51fa27de40b49062b05035a11a6c", + "ad494bd0c34049469d9b3c8fa20cf585", + "ad62ce2b77a5431db48b8d74f2a7202d", + "ad8bb461e8584d698e43e49164c0365f", + "adbb30f256954c87954b476c240d23b6", + "adf48702d0df49a6bfebc1e17ebccdbf", + "ae32f7975621402c9fa32528a75bb035", + "ae59fb6e34974b79a1baa7c9a9d3dbe0", + "afc76ace13a14193b25437d4d9011621", + "afe99d79c2e04fcbbaea5cef983d8e3c", + "b00f791866a44df7bcc27f517b5b97aa", + "b0430c999de641c288d175d16d897d27", + "b07a0f4a94064f5899f80d6d9369e630", + "b0a4293cfe274a20834cb1990d669280", + "b0ad736b45e14c67b9c278bb25eaa364", + "b0ae066aae204450a51f1209bc3419ef", + "b0bac26cd5f04d10a5103a0568897862", + "b0bf05a7e5994618870fdfd083fc2e2f", + "b0f007901e5844b8b7c4e5cf49faabb9", + "b1337481ff7f4b6ebbd2f85b8e8af85a", + "b1628a4c3b69469ab56d119f64fd82ad", + "b167a7f60cbc4b1eb87fb8cca7a618a8", + "b194904711984282b1f6e9e817d795d6", + "b19a83b81062450abd0b43f642241c77", + "b2060c525bac466aaf30ba7be4184472", + "b22d5d95571c4c3c8d40923fa8f0e717", + "b2550bea2d394fb0b077cd43c5a9df13", + "b282150879374c309d6d170beeaee292", + "b2cb7025869b4ccbab1126d1394d2bdf", + "b2d576863b754305b13f0d2775056078", + "b2fe6cb1f04d4abc8feb6a7edcf4d3d3", + "b30a2d7c2fa2463aadc7ff4673847715", + "b32be581c67545a182f5fec499a5a158", + "b3fc3176e9394cfb8f3bf61fa3650433", + "b41ec3792b934f25b0d4b5abfa66f0a7", + "b43046970de24a12b4300147568409ab", + "b432464cfc0247e3825029215a064b58", + "b4503172cf764f70ac696c3141ca2bca", + "b48781a4a8de45468ef1cab29606899b", + "b48d1016c50e468892ace173ef6d05e0", + "b4d674de42da4636bd3da33932e11e15", + "b4d810926bd84eaf83f26adbf7d0188f", + "b512089cbf0e45fd99624d314ac35622", + "b51a98bf2545471fb933995f8eb4385c", + "b55db82146aa4011b5c36d88d1f7080f", + "b57f221e15b14885bdb4d8b7e73297dc", + "b5a89061cc414994aeec2fd6802afa5c", + "b5b5ca58b44148a2add02bbd8e4ae953", + "b5e78de47b02446195331a96c3f94cab", + "b650432b65944a19ae0fa3bb59cb3949", + "b67f2dc8f40c469aa717de0450828bad", + "b6a004bbfec04551ba49782e102aff70", + "b6bdcf944f1c485089961dd1cfa2c399", + "b6d6ba85c6694d5d81ffc856571f0276", + "b73753d4f6a540cc908d55533764eab6", + "b74475eca8d44a05aa74467b3530c71b", + "b767fa939640460b82f7bb3ec2f94ec5", + "b77dfe3ec9bc416e8bd0e51945e7851c", + "b831376f11644476bb478ddfe63c8f31", + "b84ce897359a4a3aa9e6d070dd9b5448", + "b88214cc7af1409b86ffd68503b585e4", + "b90b3ba4f4f845498ba8a8e08ef57ea9", + "b91ff2b807ff45ac828c69d1840f83fd", + "b93edfe83bf843d49548108eef80f295", + "b950311bc2164c1091291a3e7f773cfc", + "b951125f055048af8234f863f89d70e7", + "b95dfeae91704770bc5d99c86ab42527", + "b980d7f2431e469594bd1eef8b826a9f", + "b985da106d764b1caafe4d35833c2797", + "ba3dd5e983a34d4098351324ed70f059", + "ba738e9f060444bc8ae1548da9b4de19", + "bb1ff784ad254ae1b083732675fc66ae", + "bb20a5fa9506480ca75225eed9c5abe1", + "bb2a51e6ca344b45b3ec757984809bd1", + "bbb2668d61ae4b4896422a8d92b48e31", + "bc3829b5c4fe41ef9166a71dc9b97a24", + "bc3db0b20a8d4c499b28a82430e95d0c", + "bc5b361f727c408c97e12bfbc4d6c3f8", + "bc6c15858b0d4436af32c0b167d9b802", + "bc6c35a4ec0b451facd34309d1fce673", + "bc6dc1f16c7849faa271e55caadc4dd9", + "bc76cd60baf945189f1acca885152776", + "bc7b80dd2bd34c4b90986430eebefea9", + "bcb50a36308b4a25bdd5954e82f6694d", + "bd0fe7034c6848ad8fde4ad1cfaf9f50", + "bd18648d84ce4e52b95effdaf3fc2f2d", + "bd36c1c9ff5d43b29c75023fa76555aa", + "bd4c37f7d9774a309cf0bd9be7d555e2", + "bd533c11907d459481cf19ffa9244ead", + "bd8354f087a740c7bbaacd49dd006818", + "bde91ed9ca604347a104c7fbfca4ec38", + "bdfba122f3454339890aab854df46b16", + "be1a659885f040c6acfc7d0deaa02c2d", + "be3c1cfb54c94329bbdfa6034e6ce8b5", + "be550d994729451e82ed5b885825ba70", + "bea2264dd77a49bf925522a895f436fa", + "bec0091d5fb54e03939081a4b7d18da8", + "bee5e56215524e0db90a36cfb1ed6554", + "bf383619a9fd4df6b236da0eb22289f8", + "bf60d8a9b98345988bca10070fd49948", + "bf8b727a205b4f1a9a1f0ceb72dfaf92", + "bfec0bac17784e978671df2806f66218", + "c09cfd32f39041c9af83cfedc209ab90", + "c0c888a0f5174584bcd3640bea9c16c2", + "c15fb31e79274b1ea73ec0165c34d179", + "c18e8dc8cc114e74845f65dcb3535f7f", + "c1c0add2e212480b8535aa54902068ca", + "c1c3974b5a9d4365a4663cca7cf2be90", + "c1c99067671d43dbb8bb7457d91fc140", + "c1cde2eb38db40c0ad5cc811490b8104", + "c1fc3c1e34b74ef78c9acf01e5301505", + "c2a42194bf0446c7b43a5b9e24b5ee5f", + "c2b22b0ce0834ab5bba0a81b21e890de", + "c2b92df7d6ba4d2bbb6a126e2c4e9c49", + "c2bd10e2eed74674870c055096a9fff6", + "c2c23a53147148da95f5b7881fdece75", + "c32183c9f6f9488aaba89c81ea672268", + "c34116d332434ead91b17c69647a82de", + "c3847e875a29468a8d6ee153a8239626", + "c3f1418e0ce64027886c9aa2951785e6", + "c4a80296a22e4860a110c10b6ce237e0", + "c4bc2c15aa9d441c9610f9483f2347c3", + "c53f2eb1ac3644d6ae4ebc12c4a46956", + "c576bfc259fa4927ba48fd62e3a3f8f8", + "c5867de023104581afcf473f24796e4f", + "c5dcc5e9e5f84063beb5ddcbf50c8356", + "c5f6c170d32e4732b6cb1cb79c6161f4", + "c603757e1db34362891f9bcc95462cf8", + "c60720d6b47745589bedd9e05551f814", + "c6698e957fa945749fc3b9aaedf3adba", + "c69de10fdd594e06bcca714a270c8008", + "c6cd9c2db199437e9850d463e5ba4e53", + "c6f3d53a005547e1bae22db6a3d6afe2", + "c6feb8cebff84fbbbae7182d16d8026a", + "c79d82557725488b83d93899dbd9719a", + "c7b7724aa259426d8a2136b3add94fc0", + "c7da12ea8deb45448e745aca869f12c3", + "c7eb255bf0d74dfdbe0d5a380c53a0f2", + "c7eb78c6be1c43769e9a1920c61911c7", + "c80802e54c89475f9887843f51dcf632", + "c81730681f9d4b5db4b225a8c31123b0", + "c820c89b751242bc893dc976023b6935", + "c8a0312d05d549e2aff7c72ec899c098", + "c9fbb5ecf4e44efc811576749781ea64", + "ca1e81405f7a41679104be449ea316d5", + "ca4f110e7130495d95795cca9bda9d98", + "ca6de369041a43b3a694b56db6db63c2", + "ca945ba5fc97490593d6cc3ff225fc1b", + "ca966d443bb643438e878fd2bc7bcd62", + "caa352932a4d47aebc02c340683fddb7", + "cabe3ffcc61346a9b957852db1d5d0b0", + "caeba102c3df4769b32bb48433642642", + "caf68e4759e14eb1822887fff2df5b5a", + "cb41e950a550484a9dda9fc2532c7eb3", + "cc109fdb15274e44a084fc15ad743521", + "cc418dc586a6417e97b7548f761f6007", + "cce0e415068d4b64b62c5af28369662b", + "cd04533338144279aa583e79d02201ec", + "cd4906bd5fba459dbeed7f6f6c36491b", + "cd59af2dfcb64191b9b1c4de9529a08c", + "cd5f6487999748088ca128ed9617937f", + "cdbd84d964714f3781b3a32918b6176c", + "cdd8e56fe16d49d7912d82661692dd72", + "ce1254ab22bc4d6d929f8266a9b88156", + "ce6d121314a24e5da3f417d68d5f89ca", + "ce85c0a5581343218c705d66ce0e6d99", + "ce9efe3775c14aa2a9845723dfe92c79", + "cf724cce0f9b4763a08d33fe5046e3aa", + "cfda855d88f34977ba7787be012e2729", + "d0247ea03b1b46688d0d05a9de462ed1", + "d029560747de4b67af53bc8917ceee88", + "d0bc143fca514bd2872c5771670db08b", + "d0cf378e25204bdc92b36de88981048a", + "d0dceec9dcce4b2388dc03dd8dff82e8", + "d12a0064abcc4e51904596d27d2a0b2d", + "d1896182e23d4940becb0b72d8596751", + "d190557c8f3644118fb2c90698b5684d", + "d29a92a302054fb4a6a754aa002addd5", + "d2c9f6b98efd4e4094ffdb58f60f8fbf", + "d337ff4890914693aca424290bcd9edb", + "d40b9d0931c14a3e9d9216ce21d739ff", + "d4117c0d8cc64567b02d47db69487522", + "d42ab5191e894c9e9f3dfc8de0861ab9", + "d46c07499bf44938905c7f1cefae56c2", + "d492441d79b248e69a97f85db63db96f", + "d4d7322334f8435dab70bb2117f2c028", + "d4d9c974cbd7460ba1869ae1eff2713e", + "d505227f186c448ca43b319c7190cce8", + "d51af3c024974fd4a55a29e3efc02778", + "d554b1b0592f4a959d308163c8198f15", + "d567c1b52a7648039bc7c745d0f0f6ea", + "d58e16be708641bbb67bef7756c264f3", + "d598ec61d4604cf2a6d169c16cd83f40", + "d5ba86e63ce34c2e8b667c08b8388fa2", + "d5fe8fa550774448bb705c918626a89c", + "d6244b772556425aa0719f72f47f5a9e", + "d693a896d5b6421b9e91da323700763c", + "d6b6beb40f3740f98d6e62cfb1dbafd6", + "d70fb711ca144e4aa88fccbd3e9b953b", + "d71a3a4aa29345dfbc6986a3a2b30404", + "d7754b178b0441ba9e7bdd745ae48b54", + "d78b9fa47c804b45b93c7ded92f496ae", + "d7b53f7a06d24bf88c13f064e8b1daf4", + "d829b2af64ed4e768ca0f5fb61923a32", + "d8954918a2cc41118bbff24bb5d92d5a", + "d8b5b0ec5b674167bb9ab95e4df8e519", + "d8b82661ee5e4468adf543c4b57dc204", + "d8d275c708824b109fa1297ebfcc74be", + "d8f51242328347e98570a58b7564c5ea", + "d933e7727823491c94025bbf886eb424", + "d9bb32ba0eb0499fbb3972c3e449c3b7", + "d9bc43234b8e4894a372f4b6e705d7af", + "da6f7ecda49f43d79897321e45c2c8d5", + "daf42136b3a4496582a99905a5bdb30b", + "db00da4ecad2464f90443946ef0d3f78", + "dbe81be2e99d467fb66b809bd2de103e", + "dc169f1f99ed44989758641543ad378f", + "dc1dc5fda19849049a1bae32f467d3ad", + "dd112e4ff0ec457e9ff99d5f05455899", + "dd8fde5ae60246fbad84ce4b514ff2cd", + "dd9bf10537b94e899cc1bb105ccd8ef5", + "dda659e7ef50458ea767236a1d32806c", + "ddaf7d39883447dcbc656c30b14d747a", + "ddf4ec21987f45afbc1dd99d70992403", + "de25557f3d2e4858a9875202e3c7ec3b", + "de3a5b4af24d473d83dd3b9b07909734", + "df1c877a3fcd4267a8017a555b9a93af", + "df62489796904cc282817d40e0cb9212", + "df743c7130744cd69cee56ed04280da9", + "e094cb8161db4528ae96da91ac44f7a1", + "e0d2565a7dd24393a5e7fd94fc196c1e", + "e103ea826b9d436095d59a1a24a740dc", + "e1c5b300a8954df186146a9ddfeda308", + "e1cc1af54f084296942a4e15e77e57aa", + "e1fd1d448ec643919138d22c7ea20e85", + "e21bbd53671842fdbd3beec7b72a6f91", + "e22ae7e7d2a040c2a413c030444ace85", + "e24a8b055f0d4595a0d25884781cfb18", + "e253e6fc24184af9b003c8144071e625", + "e25d2ad117c64b398a0311ab486d9a20", + "e28fe7fb98f74d9c9f4083e4c27303d5", + "e2a82798cd8946ecad2c7b5786ae8b0f", + "e2d82da4c81b4cc79b797e585b7aa458", + "e2ed260e82b24b48874a36abc00e45ba", + "e32d91d56182476c9b0262da1419ee53", + "e367f513bf4845fbb52981f05825df97", + "e368e3b3e03d43aba6eccfaaadcd3ece", + "e3bdbffe843d49e69259486df51ede4c", + "e400169aac0146d59e553bf27fccaf85", + "e4b4e27a424e457da2be11f531d5eabb", + "e50e543ee159433e846d4c21647374d5", + "e555ab1d60b34fdca5d53941904b12b5", + "e55a7d4e1fbf4deda86a4d7f03ed983a", + "e5894a98a94442498c614a5acc100553", + "e5b6de931ad047b5b2bc2287b098304a", + "e6008b0272484418baedd495e0459b1e", + "e6417983e4aa4999ae5abe538049b2ed", + "e65c4696efbd4942a3757902fdb2e43d", + "e6c0aa3774bf465f95f46b19f84e94b7", + "e6c69f8d7ed34b68bc2d19c3045624e2", + "e737c9e73b0645299ef9a0bf49529d04", + "e7a0c77193c64402b455d022bcdf4d7d", + "e7bd0c41280a447f92f5366aea6d8c3c", + "e7bec9dd72bd4640888237a4450ad186", + "e8126c66f1af425f9ad864849fa5c587", + "e81e0094e371465f900f371e1d25f945", + "e82a18976a5645eb9b67ebb82b6f310a", + "e84b872fbad048bdab8475d5355303ef", + "e8b865d6f0714531ac165263905b3925", + "e8def0fa68a1420aa2640a0f5877b29f", + "e948f42b9d264899913a153a0a7aeb4d", + "e95188a1d0b94ea29f57c697f25430ad", + "e9581662a69341b0851efaf8a288f5a2", + "e963e10f97d147fba938c3703e38db64", + "e99b069db48d4bcc9ed83f8564f7a49d", + "e9cc1d67e6414587a27ead0c65e7489f", + "e9dd863430f941fb801f0e1d2fcc60a8", + "e9f4fede1e90453fb081224b23d25296", + "ea57089330bf47758f7a63c891afb332", + "ea65e75d334f402980f3fac89fa61ac7", + "eae8e103012c4f3d87c8b68513d91d97", + "eaef94feb9bd44e2879fa7ba6bcde0a2", + "eb5ba7daafd245d7a519d47a956f3638", + "ebbf78a457b342b3a832aea1db4864f1", + "ec223ab74b0743e1b9ed1415b380bdd6", + "eca401a941e94d659de543259d0ebcb6", + "ecbb2420a05043f1b7399f3947b7901c", + "ecbca03a11dc4ff299d17f7f348c91bd", + "ecf9bc455a58493a85559b93144a61be", + "ed19072b62434887966eb9c001cde66f", + "ed2b8109166941988f1fa7635ad2b0c1", + "ed4fb721d6654f2ab7c40b6e08a19564", + "edb0c54997b149a0bf658b73146273ca", + "edcb0e86243f40fd9733470afb47025a", + "edcdc2e845b74de98ef076d22cd152f0", + "edfef9cdc4194d0ea9a5d0e41c0a45a1", + "ee010f9ba6aa49baa7f241899e2cc3ed", + "ee4c1ec5c2174bf7ace6f282398b03d6", + "ee5eff7c62244809b65a8f64cd24f11c", + "ee9e3e95f5034a4b9e2ec63e6eb43039", + "eef200c231894d52becfeb6b639f1f64", + "ef160c13522a43939011165a95233758", + "efac5c2201854eaea4ad75b24d9d58cb", + "efbfc2a9c75e4031a8f186a089418a43", + "effdaa6e4474477a8ea1115dbc92662b", + "f0583ba038f84cacb903dc6ce700ebbe", + "f133eafa1de1461c9e89da047ec11e77", + "f184ffda73784e97ad6b8a5ca12498e5", + "f1919e3b6baf4ce7b062e648049c8183", + "f1b21b3602ac4b648657cd19be2482a6", + "f2213805ddcd43f4ade8b437c2d5e573", + "f28c9593549a4a79971895b098cf73f7", + "f36a37e2240147ccbc587f6e29946c71", + "f3889acf84444fd283d4df0131fe444c", + "f39a09b01dd94692bcb7946a4d8ce8e6", + "f39d7b1ba8c34e10b5a8f42ce9dcc4f9", + "f3b22096447749439a9c8f65d43fca91", + "f4558a6776024bf092a84de260adbe2a", + "f4e23a8145bb498aab99b1af95191ed4", + "f4fdd94ea75e497fbdc35a7e0f37c7a8", + "f5268e2eb8b6441db2025c6724536e27", + "f569915d9d2042339a9186ff648aabc3", + "f5a0237a633b4ad080d838a394f5cc3d", + "f5cb804a5c7c4b6b92e6d004189c2de0", + "f60753651b9a4f86aa9fe8d92699a858", + "f628361b4aae47ba88110e3a3feb4e7d", + "f65bb75ce96f4af78ea8cc17bdbd9019", + "f69b2e2c5cff4a15b6af44bb7bc5ff47", + "f69fae9659324d268a41503ba4c980c7", + "f6e1e76a9c734cdeac51d1d1f925c19b", + "f6f5d26e96614822b9b077a64f04519c", + "f7ce26e5d3db4447b08a2b7446117551", + "f7f558ee88494c608153ea892b2201a9", + "f92a293cede94b46b5a70248ec365c21", + "f944dbe874ce4623af37cedec6273738", + "f9d4b0e43a264821a19dcb76831885be", + "fa2f37d15f4142f5bc9617e968a728d0", + "faf6c47b38d14b4bada9de1be87d0128", + "fb0fe88727e648f79556e35fe969d73b", + "fba794056ed84e69b9c98bc5505478a6", + "fbaeaaa71b7b4f33b843f2fba3bf1e74", + "fbafb6e97cf84ed5b349db9a26bb6321", + "fbd3e814715b43d0a5b8cb4aaa0a6362", + "fc57b7efc73340f282ede6d69b37ae92", + "fc81380838434879aff900407de394ff", + "fd1feb52d9a9406fbd3c45a523c77c3b", + "fd26d88196154c62a112c226ae34e499", + "fd3dfc2c766e472eb1aae67f63a6d556", + "fd879b84bfe24afcb84fb70e4249238f", + "fdaee3ef74fc47bbbf324501812793bc", + "fe58a8b2a003471db5eafb2e10e20482", + "fe5b68070a0940f1b84646ae9f83aafe", + "fe9ac00cb55044f893c922a386942583", + "fec0761d1ce24604b6ed8e6fbb9bea26", + "fee44ff09d2a41809952b0041f9935e2", + "ff24099feee54330aa5003a6ac8de70a", + "fff94381421e47d4b7042d6b57b53129", + "fff9e100709c42789d0212a04aa826fc" + ], + "fullReportSha256": "faf5b7fa464c16062ee973112296dbb4492bf3047af3aa65e9ec624d6e4a62e1", + "battles": 1110, + "frames": 339649, + "periodicFrames": 62266, + "periodicAdjustmentCount": 53068, + "adjustments": { + "Burn/Health/Loss": { + "count": 16480, + "netAmount": -1310646, + "absoluteAmount": 1310646, + "criticalCount": 0, + "reducedCount": 1248 + }, + "Burn/Shield/Loss": { + "count": 10650, + "netAmount": -429593, + "absoluteAmount": 429593, + "criticalCount": 0, + "reducedCount": 10196 + }, + "Damage/Health/Loss": { + "count": 16879, + "netAmount": -4385059, + "absoluteAmount": 4385059, + "criticalCount": 3852, + "reducedCount": 0 + }, + "Damage/Shield/Loss": { + "count": 7066, + "netAmount": -1087958, + "absoluteAmount": 1087958, + "criticalCount": 1547, + "reducedCount": 0 + }, + "Heal/Health/Gain": { + "count": 6510, + "netAmount": 1954466, + "absoluteAmount": 1954466, + "criticalCount": 913, + "reducedCount": 0 + }, + "Poison/Health/Loss": { + "count": 10503, + "netAmount": -1069514, + "absoluteAmount": 1069514, + "criticalCount": 0, + "reducedCount": 0 + }, + "Regen/Health/Gain": { + "count": 15435, + "netAmount": 1060113, + "absoluteAmount": 1060113, + "criticalCount": 0, + "reducedCount": 0 + }, + "Sandstorm/Health/Loss": { + "count": 3160, + "netAmount": -124305, + "absoluteAmount": 124305, + "criticalCount": 0, + "reducedCount": 0 + }, + "Sandstorm/Shield/Loss": { + "count": 3292, + "netAmount": -160118, + "absoluteAmount": 160118, + "criticalCount": 0, + "reducedCount": 0 + }, + "Shield/Shield/Gain": { + "count": 15648, + "netAmount": 3539306, + "absoluteAmount": 3539306, + "criticalCount": 1909, + "reducedCount": 0 + } + }, + "sources": { + "resolvedActions": 34078, + "unresolvedActions": 7, + "missingStatSourceIdentities": [], + "unresolvedActionSources": [ + "051572e8946f47978929a5c5f1e0a986/0/Player/PlayerModifyAttribute", + "4d52c948ad824f02a2c0199e0a312e13/0/Player/PlayerModifyAttribute", + "89a1556ee12f4700b62517124707be7e/0/Player/PlayerModifyAttribute", + "96a0666936df43d2ba06e51a0c34f3c6/0/Player/PlayerModifyAttribute", + "97c600b9118d4751bf160317cab00959/0/Player/PlayerModifyAttribute", + "fc57b7efc73340f282ede6d69b37ae92/0/Player/PlayerModifyAttribute", + "fee44ff09d2a41809952b0041f9935e2/0/Player/PlayerModifyAttribute" + ], + "multiSourceBattlesByStatus": { + "Burn": 743, + "Poison": 428, + "Regen": 343 + } + }, + "rules": { + "burnShieldBridge": { + "observed": 26073, + "matched": 25510, + "mismatched": 563, + "skipped": 0, + "matchedAmount": 1681443, + "mismatchedAmount": 58796 + }, + "burnDecay": { + "observed": 23463, + "matched": 23463, + "mismatched": 0, + "skipped": 2610, + "matchedAmount": 65066, + "mismatchedAmount": 0 + }, + "poisonTick": { + "observed": 10503, + "matched": 10362, + "mismatched": 141, + "skipped": 0, + "matchedAmount": 1032416, + "mismatchedAmount": 37098 + }, + "regenTick": { + "observed": 11627, + "matched": 11616, + "mismatched": 11, + "skipped": 3808, + "matchedAmount": 991705, + "mismatchedAmount": 9769 + }, + "healthLedger": { + "observed": 62266, + "reconciled": 62047, + "mismatched": 219, + "skipped": 0, + "attemptedGain": 1996094, + "realizedGain": 1270692, + "loss": 3741674 + }, + "shieldLedger": { + "observed": 62266, + "reconciled": 62266, + "mismatched": 0, + "skipped": 0, + "attemptedGain": 1013420, + "realizedGain": 1013420, + "loss": 812321 + }, + "realizedTotals": { + "burnHealthDamage": 1227284, + "burnShieldConsumed": 428748, + "poisonHealthDamage": 1010235, + "regenAttempted": 994167, + "regenRealized": 745285 + } + }, + "terminalImpact": { + "deathEvents": 1110, + "combatantsWithMultipleDeathEvents": 0, + "framesAfterFirstDeathEvent": 0, + "positiveHealthFramesAfterFirstDeathEvent": 0, + "periodicAdjustmentFramesAfterFirstDeathEvent": 0, + "periodicAdjustmentAmountAfterFirstDeathEvent": {}, + "periodicHealthLossOnDeathEventFrames": { + "Burn": 145563, + "Poison": 127577 + }, + "combatEffectivePeriodicHealthLossOnDeathEventFrames": { + "Burn": 62201, + "Poison": 68298 + }, + "periodicHealthOverkillOnDeathEventFrames": { + "Burn": 83362, + "Poison": 59279 + }, + "reversedOrderCombatEffectivePeriodicHealthLossOnDeathEventFrames": { + "Burn": 70438, + "Poison": 66875 + }, + "burnShieldConsumedOnDeathEventFrames": 12773, + "combatEffectiveBurnShieldConsumedOnDeathEventFrames": 118, + "burnShieldConsumedAfterLethalHealthOnDeathEventFrames": 12655, + "reversedOrderCombatEffectiveBurnShieldConsumedOnDeathEventFrames": 12632, + "atomicBurnGroupCombatEffectiveHealthLossOnDeathEventFrames": 62201, + "atomicBurnGroupCombatEffectiveShieldConsumedOnDeathEventFrames": 11928 + }, + "attribution": { + "results": 5951, + "exactResults": 1365, + "constrainedResults": 0, + "proportionalResults": 4586, + "resultsByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 639, + "Burn/Proportional": 2272, + "Poison/Constrained": 0, + "Poison/Exact": 575, + "Poison/Proportional": 1057, + "Regen/Constrained": 0, + "Regen/Exact": 151, + "Regen/Proportional": 1257 + }, + "healthByKind": { + "Burn": 1219514, + "Poison": 1001417, + "Regen": 702230 + }, + "shieldByKind": { + "Burn": 425085, + "Poison": 0, + "Regen": 0 + }, + "healthByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 313026, + "Burn/Proportional": 906488, + "Poison/Constrained": 0, + "Poison/Exact": 508709, + "Poison/Proportional": 492708, + "Regen/Constrained": 0, + "Regen/Exact": 56809, + "Regen/Proportional": 645421 + }, + "shieldByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 130023, + "Burn/Proportional": 295062, + "Poison/Constrained": 0, + "Poison/Exact": 0, + "Poison/Proportional": 0, + "Regen/Constrained": 0, + "Regen/Exact": 0, + "Regen/Proportional": 0 + }, + "allocationDecisions": 81194, + "allocationDecisionsByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 11227, + "Burn/Proportional": 39043, + "Poison/Constrained": 0, + "Poison/Exact": 6869, + "Poison/Proportional": 9683, + "Regen/Constrained": 0, + "Regen/Exact": 1833, + "Regen/Proportional": 12539 + }, + "allocatedHealthByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 345182, + "Burn/Proportional": 874332, + "Poison/Constrained": 0, + "Poison/Exact": 524822, + "Poison/Proportional": 476595, + "Regen/Constrained": 0, + "Regen/Exact": 57378, + "Regen/Proportional": 644852 + }, + "allocatedShieldByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 142484, + "Burn/Proportional": 282601, + "Poison/Constrained": 0, + "Poison/Exact": 0, + "Poison/Proportional": 0, + "Regen/Constrained": 0, + "Regen/Exact": 0, + "Regen/Proportional": 0 + }, + "unattributedHealthByKind": { + "Burn": 7770, + "Poison": 8818, + "Regen": 43055 + }, + "unattributedShieldByKind": { + "Burn": 3663, + "Poison": 0, + "Regen": 0 + }, + "unattributedHealthByOrigin": { + "ContaminatedEpoch": 16588, + "HealthLedgerUnreconciled": 7464, + "InitialState": 66, + "InitialState, MissingApplySource": 4009, + "MissingApplySource": 38, + "StatusStateAbsent": 87270 + }, + "unattributedShieldByOrigin": { + "ContaminatedEpoch": 3663, + "HealthLedgerUnreconciled": 0, + "InitialState": 0, + "InitialState, MissingApplySource": 0, + "MissingApplySource": 0, + "StatusStateAbsent": 0 + }, + "unattributedHealthByContext": { + "ContaminatedEpoch/MeasuredSource/ApplySameFrame/Later": 155, + "ContaminatedEpoch/MeasuredSource/NoApply/Later": 16433, + "HealthLedgerUnreconciled/Excluded/ApplySameFrame/Later": 2233, + "HealthLedgerUnreconciled/Excluded/NoApply/Later": 5231, + "InitialState, MissingApplySource/MeasuredSource/ApplySameFrame/Later": 212, + "InitialState, MissingApplySource/MeasuredSource/NoApply/Later": 3797, + "InitialState/MeasuredSource/NoApply/Frame0": 2, + "InitialState/MeasuredSource/NoApply/Later": 64, + "MissingApplySource/MeasuredSource/NoApply/Later": 38, + "StatusStateAbsent/Excluded/NoApply/Frame0": 152, + "StatusStateAbsent/Excluded/NoApply/Later": 48176, + "StatusStateAbsent/MeasuredSource/NoApply/Frame0": 57, + "StatusStateAbsent/MeasuredSource/NoApply/Later": 38885 + }, + "unattributedRegenByCandidateSurfaces": { + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:0/prior:0/display:0": 491, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:1/prior:1/display:1": 205, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:2/prior:2/display:2": 57, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:3/prior:3/display:3": 97, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:5/prior:5/display:5": 172, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:1/events:0/prior:0/display:0": 98, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:3/events:0/prior:0/display:0": 38, + "HealthLedgerUnreconciled/Excluded/stats:1/attributes:1/events:1/prior:1/display:1": 436, + "HealthLedgerUnreconciled/Excluded/stats:1/attributes:3/events:1/prior:1/display:1": 120, + "HealthLedgerUnreconciled/Excluded/stats:2/attributes:2/events:2/prior:1/display:2": 822, + "HealthLedgerUnreconciled/Excluded/stats:2/attributes:2/events:2/prior:2/display:2": 3400, + "HealthLedgerUnreconciled/Excluded/stats:2/attributes:3/events:2/prior:2/display:2": 143, + "HealthLedgerUnreconciled/Excluded/stats:3/attributes:4/events:3/prior:3/display:3": 1343, + "HealthLedgerUnreconciled/Excluded/stats:4/attributes:5/events:4/prior:4/display:4": 42, + "InitialState, MissingApplySource/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 1747, + "InitialState, MissingApplySource/MeasuredSource/stats:0/attributes:0/events:1/prior:0/display:1": 47, + "InitialState, MissingApplySource/MeasuredSource/stats:0/attributes:0/events:1/prior:1/display:1": 1865, + "InitialState, MissingApplySource/MeasuredSource/stats:2/attributes:3/events:2/prior:0/display:2": 92, + "InitialState, MissingApplySource/MeasuredSource/stats:2/attributes:3/events:2/prior:1/display:2": 68, + "InitialState, MissingApplySource/MeasuredSource/stats:2/attributes:3/events:2/prior:2/display:2": 190, + "InitialState/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 66, + "MissingApplySource/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 38, + "StatusStateAbsent/Excluded/stats:0/attributes:0/events:0/prior:0/display:0": 48328, + "StatusStateAbsent/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 38942 + } + } +} diff --git a/tests/PeriodicEffectAttribution.Corpus/evidence/periodic-impact-v6-production-2026-08-04.json b/tests/PeriodicEffectAttribution.Corpus/evidence/periodic-impact-v6-production-2026-08-04.json new file mode 100644 index 00000000..5f8567ba --- /dev/null +++ b/tests/PeriodicEffectAttribution.Corpus/evidence/periodic-impact-v6-production-2026-08-04.json @@ -0,0 +1,1906 @@ +{ + "evidenceSchemaVersion": 1, + "corpusSchemaVersion": 4, + "modelVersion": "periodic-impact-v6", + "sourceKind": "run_bundles", + "sourceCount": 100, + "sourceArtifacts": [ + { + "name": "031fe27e87032271f12c6fcc34dfe835ba36469cda9d73e3619157d74bb3df60.mpack.gz", + "sha256": "031fe27e87032271f12c6fcc34dfe835ba36469cda9d73e3619157d74bb3df60" + }, + { + "name": "0428ebb0c3da06dfe2c651e586132dff7c2a52195f58d86b6afb5496a45b69f8.mpack.gz", + "sha256": "0428ebb0c3da06dfe2c651e586132dff7c2a52195f58d86b6afb5496a45b69f8" + }, + { + "name": "05300afab726ab33ce51565796d43d47428c2efc736d88017c74ea196d522a43.mpack.gz", + "sha256": "05300afab726ab33ce51565796d43d47428c2efc736d88017c74ea196d522a43" + }, + { + "name": "07687b20194413b649fee079114a71d99bbdf86ef0ffda9cdff1f7598a176780.mpack.gz", + "sha256": "07687b20194413b649fee079114a71d99bbdf86ef0ffda9cdff1f7598a176780" + }, + { + "name": "0f54b40e6a61c98035e841fa9da799bd3d1a07b776a985a41b9824b5e3eb6cd0.mpack.gz", + "sha256": "0f54b40e6a61c98035e841fa9da799bd3d1a07b776a985a41b9824b5e3eb6cd0" + }, + { + "name": "134e79569681f9fdabf892f2ec34b5db7ea1a21c90c225c675981dc6a7eab3a8.mpack.gz", + "sha256": "134e79569681f9fdabf892f2ec34b5db7ea1a21c90c225c675981dc6a7eab3a8" + }, + { + "name": "150dc0b327246c50656e92503af692d86a7eb5e72eebbb267d1173d63adc2371.mpack.gz", + "sha256": "150dc0b327246c50656e92503af692d86a7eb5e72eebbb267d1173d63adc2371" + }, + { + "name": "18d61d52132b798393aeda0a1265702fd7f701b4259c482e72ae6960eca4d57f.mpack.gz", + "sha256": "18d61d52132b798393aeda0a1265702fd7f701b4259c482e72ae6960eca4d57f" + }, + { + "name": "192d747959f5fc97c9846b576f4ed97fdf0a8aed4f8e2c6a754720af8269ab2f.mpack.gz", + "sha256": "192d747959f5fc97c9846b576f4ed97fdf0a8aed4f8e2c6a754720af8269ab2f" + }, + { + "name": "22de07f79003fc1baaaad346b960880bafbf98a751ec55371b44203f3a5f1a8c.mpack.gz", + "sha256": "22de07f79003fc1baaaad346b960880bafbf98a751ec55371b44203f3a5f1a8c" + }, + { + "name": "259e901ea14b60445b260fc07c29fab4c66d4f5c03ee1d839f0a8b3ebd5e1def.mpack.gz", + "sha256": "259e901ea14b60445b260fc07c29fab4c66d4f5c03ee1d839f0a8b3ebd5e1def" + }, + { + "name": "262f477fee7e4cdcbc3915cf7e80ee07a423fe59398c938f00c0c1494ec0e369.mpack.gz", + "sha256": "262f477fee7e4cdcbc3915cf7e80ee07a423fe59398c938f00c0c1494ec0e369" + }, + { + "name": "2696a7cd990a0eb23485cf2449ba82eb4ad9338295a98ab2c184fd71e4b38f75.mpack.gz", + "sha256": "2696a7cd990a0eb23485cf2449ba82eb4ad9338295a98ab2c184fd71e4b38f75" + }, + { + "name": "26c2b07a665880c80a7357f88917338d72e5a517b340904b127f879ea4bd65f7.mpack.gz", + "sha256": "26c2b07a665880c80a7357f88917338d72e5a517b340904b127f879ea4bd65f7" + }, + { + "name": "29a409df70d1c637b39b28f068ab91a3e7ce060c38f8a8f094cb91f37c554a3d.mpack.gz", + "sha256": "29a409df70d1c637b39b28f068ab91a3e7ce060c38f8a8f094cb91f37c554a3d" + }, + { + "name": "2aba7d6a8209929a164267f91e2203a00194fd1a1d92f6a0146bd99e8bbf82de.mpack.gz", + "sha256": "2aba7d6a8209929a164267f91e2203a00194fd1a1d92f6a0146bd99e8bbf82de" + }, + { + "name": "2ff97dda4d4521ec25e62b7e249e295cabbda9fa3af314dc404c424cd80fdbda.mpack.gz", + "sha256": "2ff97dda4d4521ec25e62b7e249e295cabbda9fa3af314dc404c424cd80fdbda" + }, + { + "name": "36c1143c1ad2098a8a964bf3f5e636f3b99603df117f4747292e58f126a4ecc1.mpack.gz", + "sha256": "36c1143c1ad2098a8a964bf3f5e636f3b99603df117f4747292e58f126a4ecc1" + }, + { + "name": "39e9544fb18713f145c1f7379710078188bacb76f22b1115b0f5c727c7063c20.mpack.gz", + "sha256": "39e9544fb18713f145c1f7379710078188bacb76f22b1115b0f5c727c7063c20" + }, + { + "name": "3fbdcb44d50925f74ff70208bc23572f2b9c6d0d1496920ff39985c2f40d5970.mpack.gz", + "sha256": "3fbdcb44d50925f74ff70208bc23572f2b9c6d0d1496920ff39985c2f40d5970" + }, + { + "name": "401d599953c7f2a975f5dfe4757647aecb852cb57396df6af1b57dfa200d08e9.mpack.gz", + "sha256": "401d599953c7f2a975f5dfe4757647aecb852cb57396df6af1b57dfa200d08e9" + }, + { + "name": "40db78bbdfb59f4aef31decb3a704c9517db51084abb59e1fedb002569242215.mpack.gz", + "sha256": "40db78bbdfb59f4aef31decb3a704c9517db51084abb59e1fedb002569242215" + }, + { + "name": "4395770d65d151fa6a57e63194e5125e020ac13b4acd1ada3e0ddace1889b483.mpack.gz", + "sha256": "4395770d65d151fa6a57e63194e5125e020ac13b4acd1ada3e0ddace1889b483" + }, + { + "name": "43a0306e480ec032f170f86a58862fa66896c80db3c6a6be1fbf9fd2ab75be48.mpack.gz", + "sha256": "43a0306e480ec032f170f86a58862fa66896c80db3c6a6be1fbf9fd2ab75be48" + }, + { + "name": "444b8dcc1b6a0c92ab71a87c3ff6fd49c85b74a3be4accb1d6f403901cf77d3c.mpack.gz", + "sha256": "444b8dcc1b6a0c92ab71a87c3ff6fd49c85b74a3be4accb1d6f403901cf77d3c" + }, + { + "name": "452978a2c887a7c31ffd014c8860620df742088528068aa39d5e600d1269fb70.mpack.gz", + "sha256": "452978a2c887a7c31ffd014c8860620df742088528068aa39d5e600d1269fb70" + }, + { + "name": "45b1d6667bdc7b5f979335f96ebf28b960037d9ecabdf50facdcbfebb50fbead.mpack.gz", + "sha256": "45b1d6667bdc7b5f979335f96ebf28b960037d9ecabdf50facdcbfebb50fbead" + }, + { + "name": "4851ac98eec4dd308d2a037a68fc59f90ede253e4ae0b6e68b8c511ac86a5344.mpack.gz", + "sha256": "4851ac98eec4dd308d2a037a68fc59f90ede253e4ae0b6e68b8c511ac86a5344" + }, + { + "name": "497306d67095543c7a99a34b618aaa8bfa6f2fc916280aacac166cc5af5384f2.mpack.gz", + "sha256": "497306d67095543c7a99a34b618aaa8bfa6f2fc916280aacac166cc5af5384f2" + }, + { + "name": "4b60124c0670de6f989938dc4f8f49c3194923fcb564c36ee070faf184fe0844.mpack.gz", + "sha256": "4b60124c0670de6f989938dc4f8f49c3194923fcb564c36ee070faf184fe0844" + }, + { + "name": "4e45e32d49933105b9383f07562d0e4f9b1be039b5d5b84d6b55465955885997.mpack.gz", + "sha256": "4e45e32d49933105b9383f07562d0e4f9b1be039b5d5b84d6b55465955885997" + }, + { + "name": "4f2621d6b92a4a599b65af1efede7087d8b6d41b165825a79dfd39d5056ee698.mpack.gz", + "sha256": "4f2621d6b92a4a599b65af1efede7087d8b6d41b165825a79dfd39d5056ee698" + }, + { + "name": "5000c703e1c2d38d8676640a5293b331f3d64a66b14aa737e2e6994ecdc4d4da.mpack.gz", + "sha256": "5000c703e1c2d38d8676640a5293b331f3d64a66b14aa737e2e6994ecdc4d4da" + }, + { + "name": "53da4148b4573b5c3795bd03b3fa2ce978fcbb4e05940635d3ecc1518632532a.mpack.gz", + "sha256": "53da4148b4573b5c3795bd03b3fa2ce978fcbb4e05940635d3ecc1518632532a" + }, + { + "name": "54aefcd22c0c17fe74bc0fde206ee3775fb511cb69116bca2f167225caeafc79.mpack.gz", + "sha256": "54aefcd22c0c17fe74bc0fde206ee3775fb511cb69116bca2f167225caeafc79" + }, + { + "name": "55e6d3b28fd1ba0d97959144934e15030734a024f8e51da51285ae06cbc6586b.mpack.gz", + "sha256": "55e6d3b28fd1ba0d97959144934e15030734a024f8e51da51285ae06cbc6586b" + }, + { + "name": "577a614cef4a3fc9f2602d5a9b606a2833ecafe88bdf40bab51d2a96529a3537.mpack.gz", + "sha256": "577a614cef4a3fc9f2602d5a9b606a2833ecafe88bdf40bab51d2a96529a3537" + }, + { + "name": "58170117fedd20823368e7f6d47c78b0b5b91bd03c207afd34352433a9dee71c.mpack.gz", + "sha256": "58170117fedd20823368e7f6d47c78b0b5b91bd03c207afd34352433a9dee71c" + }, + { + "name": "58ed42e6f311193130133584892c3a66a3bdc58ac15d934f03268fa269bb4658.mpack.gz", + "sha256": "58ed42e6f311193130133584892c3a66a3bdc58ac15d934f03268fa269bb4658" + }, + { + "name": "59d6b0234fd08a1b7c0442f934185964c6de22a8ad3c5941777febc1e6f7041e.mpack.gz", + "sha256": "59d6b0234fd08a1b7c0442f934185964c6de22a8ad3c5941777febc1e6f7041e" + }, + { + "name": "621e37b94d6d42847bfb3a95df7cf01b06b792f0962c99a494a0b728a6796797.mpack.gz", + "sha256": "621e37b94d6d42847bfb3a95df7cf01b06b792f0962c99a494a0b728a6796797" + }, + { + "name": "658fcc8c2f08be95207c739b21e611b7557c05db545a74a6995740ae84d49ee4.mpack.gz", + "sha256": "658fcc8c2f08be95207c739b21e611b7557c05db545a74a6995740ae84d49ee4" + }, + { + "name": "6ab4261e8a1258dbe4cf3b7b0ae6dfcf8992b8489d6f3e289547e2fa2182815a.mpack.gz", + "sha256": "6ab4261e8a1258dbe4cf3b7b0ae6dfcf8992b8489d6f3e289547e2fa2182815a" + }, + { + "name": "6b1cdf5163cea201c90cb7d1c25244f0444314354a7f082cf5cc0d024deee878.mpack.gz", + "sha256": "6b1cdf5163cea201c90cb7d1c25244f0444314354a7f082cf5cc0d024deee878" + }, + { + "name": "6db41cb654608c3d5320d8b512e22d4b6a53b2f62ddd4ed67507ad21a1133317.mpack.gz", + "sha256": "6db41cb654608c3d5320d8b512e22d4b6a53b2f62ddd4ed67507ad21a1133317" + }, + { + "name": "72c7ccd223c8569773aea980d4e81cab00dbfdb911d3af6393b4c70e924edccb.mpack.gz", + "sha256": "72c7ccd223c8569773aea980d4e81cab00dbfdb911d3af6393b4c70e924edccb" + }, + { + "name": "76939bbaff12c97a5d926a03a760621b7ce94711fe6495e116824e5bda3f87fb.mpack.gz", + "sha256": "76939bbaff12c97a5d926a03a760621b7ce94711fe6495e116824e5bda3f87fb" + }, + { + "name": "785f52c01db948cfeb9214f2f2a863b77e1e7f5cadeaa43f9cd6558b4a83b552.mpack.gz", + "sha256": "785f52c01db948cfeb9214f2f2a863b77e1e7f5cadeaa43f9cd6558b4a83b552" + }, + { + "name": "7d5665f35b07ec3cee445a50a75b5a13abeb1d0e9e2341e6729c5ba720c8c2a6.mpack.gz", + "sha256": "7d5665f35b07ec3cee445a50a75b5a13abeb1d0e9e2341e6729c5ba720c8c2a6" + }, + { + "name": "7e53e30e2fe7b59b57fd8ca645844efe4a395c6a6b8955db81faa6c7cb81b0fa.mpack.gz", + "sha256": "7e53e30e2fe7b59b57fd8ca645844efe4a395c6a6b8955db81faa6c7cb81b0fa" + }, + { + "name": "7ff92985b8e998a95e3ffb9d5705306810c8c490b991fa648f733f39e777b892.mpack.gz", + "sha256": "7ff92985b8e998a95e3ffb9d5705306810c8c490b991fa648f733f39e777b892" + }, + { + "name": "80b66b9c93dc391c45b1f771a2b23b8758b3d134996d2893a8047a1bf3f12d08.mpack.gz", + "sha256": "80b66b9c93dc391c45b1f771a2b23b8758b3d134996d2893a8047a1bf3f12d08" + }, + { + "name": "819ef08a1f63f9c257222f9b7f106e7ad5abbc5681785cd58458701d09873aba.mpack.gz", + "sha256": "819ef08a1f63f9c257222f9b7f106e7ad5abbc5681785cd58458701d09873aba" + }, + { + "name": "8644ad09756ab84bf4cf8f35ad6a4ff9a7bb0b4b3a6458b22bd0981f1472becc.mpack.gz", + "sha256": "8644ad09756ab84bf4cf8f35ad6a4ff9a7bb0b4b3a6458b22bd0981f1472becc" + }, + { + "name": "8804134abaec6f8a38221334e3a957f9a602c3a7a1c550e3759b723b5277808f.mpack.gz", + "sha256": "8804134abaec6f8a38221334e3a957f9a602c3a7a1c550e3759b723b5277808f" + }, + { + "name": "88bc4f79c6cfc379675b03e7857af055065e4eb0a9bc111474afbc0160541084.mpack.gz", + "sha256": "88bc4f79c6cfc379675b03e7857af055065e4eb0a9bc111474afbc0160541084" + }, + { + "name": "8b00fe7f436cbc716fffc31c9188900e32510308d8468cf9384b99c05ed1df38.mpack.gz", + "sha256": "8b00fe7f436cbc716fffc31c9188900e32510308d8468cf9384b99c05ed1df38" + }, + { + "name": "8c75ddbff78ca333a5f4c75dad39c45f1a597ff66cb6bb3917bc2df8ef80653a.mpack.gz", + "sha256": "8c75ddbff78ca333a5f4c75dad39c45f1a597ff66cb6bb3917bc2df8ef80653a" + }, + { + "name": "93946489c4f4f0ef399a4a2970f8b456fdaa8a85d480ff0aff306353a6e47723.mpack.gz", + "sha256": "93946489c4f4f0ef399a4a2970f8b456fdaa8a85d480ff0aff306353a6e47723" + }, + { + "name": "964ab0b26a4b32cee769cea28cdf7be2b20348c6625bc2dffc6a20fc83f87051.mpack.gz", + "sha256": "964ab0b26a4b32cee769cea28cdf7be2b20348c6625bc2dffc6a20fc83f87051" + }, + { + "name": "964e114342fe58bb90dbf4d5193ce3d983ad38e3185f4e54ce3beb4cf903f9e5.mpack.gz", + "sha256": "964e114342fe58bb90dbf4d5193ce3d983ad38e3185f4e54ce3beb4cf903f9e5" + }, + { + "name": "9776ccbb12bf032de9f638584c7286d1b188b4c80f8dc5637fcfcef2cb9c21fd.mpack.gz", + "sha256": "9776ccbb12bf032de9f638584c7286d1b188b4c80f8dc5637fcfcef2cb9c21fd" + }, + { + "name": "99c5833f61bdf37636c85f593edf2499b1b54d0c5d35609d401904bced100226.mpack.gz", + "sha256": "99c5833f61bdf37636c85f593edf2499b1b54d0c5d35609d401904bced100226" + }, + { + "name": "9dbc14e682afb5bb2f94377d13ccfe6b164c925062ac960cc55c673b9b768f77.mpack.gz", + "sha256": "9dbc14e682afb5bb2f94377d13ccfe6b164c925062ac960cc55c673b9b768f77" + }, + { + "name": "9ec68f1d03be4364f15e5156c8af5df3f07146be7ff5e49d2595484a47e98a8e.mpack.gz", + "sha256": "9ec68f1d03be4364f15e5156c8af5df3f07146be7ff5e49d2595484a47e98a8e" + }, + { + "name": "a059e5be5f2d7f1ce575f874f415a9ab5e7b342623626f6ffa199df1b28166d6.mpack.gz", + "sha256": "a059e5be5f2d7f1ce575f874f415a9ab5e7b342623626f6ffa199df1b28166d6" + }, + { + "name": "a09dac2f35b698812272b9504c0eec5e582570b3c1964a6a51d59038f7b16e19.mpack.gz", + "sha256": "a09dac2f35b698812272b9504c0eec5e582570b3c1964a6a51d59038f7b16e19" + }, + { + "name": "a3d2fcd0aea54c090e7a26d3a0525f4eb8a7a10c842eaf87ad702d82caad9e4f.mpack.gz", + "sha256": "a3d2fcd0aea54c090e7a26d3a0525f4eb8a7a10c842eaf87ad702d82caad9e4f" + }, + { + "name": "a812d9e8063ee6d3e29e2ed9bc0a88e2d8683e231912133a424241c6a3564d37.mpack.gz", + "sha256": "a812d9e8063ee6d3e29e2ed9bc0a88e2d8683e231912133a424241c6a3564d37" + }, + { + "name": "a8742b2bb8b3e5640f0e47a11479d1e003a79f5ea3c7ad802b15ad42b7b1b799.mpack.gz", + "sha256": "a8742b2bb8b3e5640f0e47a11479d1e003a79f5ea3c7ad802b15ad42b7b1b799" + }, + { + "name": "a88effad775a95e727d906c69c4bb0126f569678ffb6897a8ce1b8a36e2b91f7.mpack.gz", + "sha256": "a88effad775a95e727d906c69c4bb0126f569678ffb6897a8ce1b8a36e2b91f7" + }, + { + "name": "ab2b7e7160e5836d5a9c0e97718e15ab3f828f11fdc4d5938fb1e49a7a1756a5.mpack.gz", + "sha256": "ab2b7e7160e5836d5a9c0e97718e15ab3f828f11fdc4d5938fb1e49a7a1756a5" + }, + { + "name": "ad98cc551da9941544c3a79be4f143c4413c3c5564c4c2c80ae7dce130692302.mpack.gz", + "sha256": "ad98cc551da9941544c3a79be4f143c4413c3c5564c4c2c80ae7dce130692302" + }, + { + "name": "b46fffb9df7457765f370d05590732f296541e8884febf1d19c83146981335e0.mpack.gz", + "sha256": "b46fffb9df7457765f370d05590732f296541e8884febf1d19c83146981335e0" + }, + { + "name": "b59ff36cf2bf65530c2f33faa7ca62dcdae9c8372d3aed9718138c92256cc10d.mpack.gz", + "sha256": "b59ff36cf2bf65530c2f33faa7ca62dcdae9c8372d3aed9718138c92256cc10d" + }, + { + "name": "b61cac63c8e4987a266af873febbb417cf6bec08907ae15b11f54d6ba2f800fd.mpack.gz", + "sha256": "b61cac63c8e4987a266af873febbb417cf6bec08907ae15b11f54d6ba2f800fd" + }, + { + "name": "b681d8a290dd5f51d3ab58d3bbbed428497ee9b107498f0c0f9620349cfcb5d2.mpack.gz", + "sha256": "b681d8a290dd5f51d3ab58d3bbbed428497ee9b107498f0c0f9620349cfcb5d2" + }, + { + "name": "b77bce130551dd22b5f49c090ba19bab3ec041298e94af778928b85279b560c1.mpack.gz", + "sha256": "b77bce130551dd22b5f49c090ba19bab3ec041298e94af778928b85279b560c1" + }, + { + "name": "b9fa3bb93f9b7ba718a3b40b373480931c29beaa13e5fe7927c36e84edbe7fce.mpack.gz", + "sha256": "b9fa3bb93f9b7ba718a3b40b373480931c29beaa13e5fe7927c36e84edbe7fce" + }, + { + "name": "bc73cab5410b43e704d35ab710f917cc5b10c421d7619d21b7f0be9bdb4204b1.mpack.gz", + "sha256": "bc73cab5410b43e704d35ab710f917cc5b10c421d7619d21b7f0be9bdb4204b1" + }, + { + "name": "c0b86de049eed12a499f9acf3960d798fcac8b452da7e3a36b8718309e2417ab.mpack.gz", + "sha256": "c0b86de049eed12a499f9acf3960d798fcac8b452da7e3a36b8718309e2417ab" + }, + { + "name": "c502ebef150061b3c271bc1cc759fe34e9e07b9e3babccae6089a4472bbbf01f.mpack.gz", + "sha256": "c502ebef150061b3c271bc1cc759fe34e9e07b9e3babccae6089a4472bbbf01f" + }, + { + "name": "c55a81f3b9489bc82d9f74a373100a4fa38d7c314d13606e68eaa5333f5b0f92.mpack.gz", + "sha256": "c55a81f3b9489bc82d9f74a373100a4fa38d7c314d13606e68eaa5333f5b0f92" + }, + { + "name": "c66933d1421734879b0d0f97941ac8c9ae7c68ac033691bf93322f58083df649.mpack.gz", + "sha256": "c66933d1421734879b0d0f97941ac8c9ae7c68ac033691bf93322f58083df649" + }, + { + "name": "c8344f44279e11deff162f3132932027188d4feafdfda39326dcb00ed9155f0f.mpack.gz", + "sha256": "c8344f44279e11deff162f3132932027188d4feafdfda39326dcb00ed9155f0f" + }, + { + "name": "c89611779c12805b9263836ccb5ef62d67539929f3abd6d91149a37854cb24ac.mpack.gz", + "sha256": "c89611779c12805b9263836ccb5ef62d67539929f3abd6d91149a37854cb24ac" + }, + { + "name": "cbf58f19aaf05ffe9a2078d13cc4120239ff559201819a3a1036e6eb49b332f4.mpack.gz", + "sha256": "cbf58f19aaf05ffe9a2078d13cc4120239ff559201819a3a1036e6eb49b332f4" + }, + { + "name": "cc0b6ad1d111964b9de135526878cab79ac757594104fcabc8a34823919f66c4.mpack.gz", + "sha256": "cc0b6ad1d111964b9de135526878cab79ac757594104fcabc8a34823919f66c4" + }, + { + "name": "d44a13d1956bf9d550ec123eced97efc4b3c21ab64852bc73062921386e8603f.mpack.gz", + "sha256": "d44a13d1956bf9d550ec123eced97efc4b3c21ab64852bc73062921386e8603f" + }, + { + "name": "d6abd55b1028f41e11ecd26652eaed6045a660698b5009cc9a61cc334dc0c558.mpack.gz", + "sha256": "d6abd55b1028f41e11ecd26652eaed6045a660698b5009cc9a61cc334dc0c558" + }, + { + "name": "d9895f3b288debe365f8dbc93e84d04ece7a98e097414bef2dcc398f81a7eb3e.mpack.gz", + "sha256": "d9895f3b288debe365f8dbc93e84d04ece7a98e097414bef2dcc398f81a7eb3e" + }, + { + "name": "e2fc3aa9cdc17939bd346915ddf2695faf801827bad4e36436bf210d8d3c3baa.mpack.gz", + "sha256": "e2fc3aa9cdc17939bd346915ddf2695faf801827bad4e36436bf210d8d3c3baa" + }, + { + "name": "e8e98c2eacbd5cbd0538cd61a4e852edd7faaba6ce7457ee444f7d3fc17d5d15.mpack.gz", + "sha256": "e8e98c2eacbd5cbd0538cd61a4e852edd7faaba6ce7457ee444f7d3fc17d5d15" + }, + { + "name": "ead723cd3563643665e4639cde301e592e56b98820ca2ec7333d2705cf3264cd.mpack.gz", + "sha256": "ead723cd3563643665e4639cde301e592e56b98820ca2ec7333d2705cf3264cd" + }, + { + "name": "ef1a4208dc4088d47eb7bd1514284b2634f2f25f360e698af1d04e3223ca2906.mpack.gz", + "sha256": "ef1a4208dc4088d47eb7bd1514284b2634f2f25f360e698af1d04e3223ca2906" + }, + { + "name": "f09cc673597232a0b5decebb525a07518fcc57e6b013d720376ffcbd10c5bc43.mpack.gz", + "sha256": "f09cc673597232a0b5decebb525a07518fcc57e6b013d720376ffcbd10c5bc43" + }, + { + "name": "f24288c51cbc6eb623df0daa99752004a55e23b5cddb11d9b660ad11a089e4a7.mpack.gz", + "sha256": "f24288c51cbc6eb623df0daa99752004a55e23b5cddb11d9b660ad11a089e4a7" + }, + { + "name": "fac7ef3eab9d069dfb61539ff8531c8d024f391dc4b3a430aa9cbfcee8ec22a4.mpack.gz", + "sha256": "fac7ef3eab9d069dfb61539ff8531c8d024f391dc4b3a430aa9cbfcee8ec22a4" + }, + { + "name": "fd36a8a3ffa1eb432b80c83874f49874452d5ff3bfeb3ee4815a8bc5e50a5a9f.mpack.gz", + "sha256": "fd36a8a3ffa1eb432b80c83874f49874452d5ff3bfeb3ee4815a8bc5e50a5a9f" + }, + { + "name": "ffa1461246f4eea5ddc89d2b17a332f592455dd30a9c9484e9d7c2b2f9da7657.mpack.gz", + "sha256": "ffa1461246f4eea5ddc89d2b17a332f592455dd30a9c9484e9d7c2b2f9da7657" + } + ], + "battleIds": [ + "0058461536694c0a8d32026d7c8eb86c", + "00a5d66b1e3a4e2ea859d923802a5b60", + "00aa996be1e94604897e54c3ddfb0b42", + "00b3e1dc8c384079b36a8ef786a03508", + "00cac3dbc0cb4fb485addef70fae2ada", + "00d209f3dd634de197ccab997392d373", + "0158c91c38cc4ccba1ebd18db95836b6", + "01c2c10032224ee59f45c52d16b3098e", + "02388a7108d4470ebae9633c006eb2e8", + "0291b9db652145c28bcea6dd277ab8d5", + "032c8f46241c440697b1bad77097a5ef", + "034774f6fcc74051bc92c94bdde5f0d6", + "034de0051c0c4de1a579bb1cd3230765", + "0370abd93c8649b2b47deaeb2e30f57a", + "037ae93801ba407183743f992fed5e83", + "03a4c643b2b445e5b08ec6266c0017ad", + "04c5556626d341f3a49c60ba5722a2d3", + "050028e25c81463d9226f9aa27009850", + "051572e8946f47978929a5c5f1e0a986", + "05470568407645578d93f75ccffac9a4", + "05c9561ac8d64f33b4a81b377e53dd77", + "05fbe509e7dc4365b83080b5cd52bd74", + "064fd2a36e4d47d39303a3cad1c5a992", + "069917dd4c2d4d3d99a05b729936face", + "06f75640ff7342ea9d4031eed2536b8d", + "07160909ce3c44728249bdc8f31a0792", + "0737e8264c00452f8d810156683f13c8", + "07b82b7beee443ff87d75764ad198484", + "07c2042e11e8460fa654b2448da43e00", + "07d52075273648f8914c4fcc664a5eb4", + "085070a446454200835572a0cea2eaca", + "08fbb96a6cc04285be132c894ed336d2", + "09cb4e941c654827bd34ad85f894b6d0", + "0a2b771219014d38bd888cec0f67ec65", + "0a94a45835a747aa9f5c5c76a51127c9", + "0aae25ae3cc34d2aa9e7cdf2e30c60b3", + "0ab10593c0b84be891f894f58dd69c09", + "0b1a1a51e9aa40478d5638b958383261", + "0b32ac7202494071aba73bced027f844", + "0b53897177474cb7bdb3359dc57a5d86", + "0baedfdfac1b491891bc82899d1c46fb", + "0bc7f6e0d1554f4eacfbff978600a45f", + "0bd6ae8a491549989ef5463444f36c27", + "0be1e15a35fa46b0b7ac9c256c25f80b", + "0c0eed8a0ca74f5a8a64ea1a1685e903", + "0c6a8014986d426593a5685c0341ce82", + "0d405d496bd141e1980e52e6b5469f33", + "0d9dc368baa54296a2168cdce99f5436", + "0dac4fa927544801b617d04ac5b681dd", + "0e8639c8a95e432c91ce5470012b4036", + "0ee84ad368b44812afe2319bfad182bd", + "0f63f51c2ab8499daebdd7b5fcde330b", + "0f90406e78954d6dad5b5457cc976000", + "0fc40e6426184daa8840100f3a192918", + "0feceb66ebdc4858858be0d8a0aca483", + "0ff3ad18f3d447bea9b948580beac6f4", + "1005c0ae43eb4d09a4085973946c3e1b", + "10116385e5854e11b4a5e6ba03351a4c", + "101f772c1e8040da8c8ea616497623b3", + "1024fc69f27b4b838c8968c72a81e7d1", + "102efdd8c9ef49c5aff250fdb48730c1", + "10843ac3b5064c3a8db3034d36aaec9f", + "10ae37e2c7dc4923a579e630c359e10f", + "10c2673a491747cc80c709bf17ae29b8", + "10eaf7ce8f2d4c24865d685261f1d614", + "10f8f6b98cac4e26a23cc618cb6fe523", + "111b42f13cf74dc7b857c7f7422c7bf1", + "1149595735a043a9a99055ccf3e188ce", + "114b23e7b88c465bb19dc04ae3c80eb8", + "11e0bfbb28fb46b28d1518dca3ffd170", + "123a04ea10fe4a19bce92d635907c849", + "12599948ac024871a813bdb4beb8f655", + "12fa8fe3c23848bf805bdb7ae6ff4da6", + "1336463d9ccb48b1988c74eae23120ce", + "137789db84b544b08d28e1f7c8632ff5", + "1388a5fa78e64db59e165c68e8437e33", + "13ec55eb40334010bd4c6935b00cf28d", + "143ded3b7e5446079f525c5f0366361f", + "144a12fd7eac4132afaa1b5619252a7f", + "1586cbe600044dd5bf556cfe627e8514", + "15a0fc0db2f246368ef09ad1bb959caa", + "16ec2c2334674ce1a18612692d65fa93", + "1738ab787b4a4f7d9c7a0901b1e7f59c", + "178966d97dd94506b725a2ef6629e6dc", + "179142a9364c4cc79b1ee3a95cc5f256", + "17a1d1c27646436d89e40d064430148a", + "17c27ce0f4c94bb98c2322d2e9dfd873", + "17c9c61db0ea424fae5cd7c9841f364e", + "180ae04a0a1046a187eb7caf9994af15", + "181c991a815c4343882db045cb38f460", + "184bf8ecbff345379878a2c33da53e28", + "1852bd25ee2947aeb8643c78195ed7ea", + "187a3d63ae754c5b992747475c10ebf0", + "189c21699ba24c7f9232039b232789c8", + "189d9eca60a64d5aba3fdf9ac1cbdc3a", + "18fb232b42ff43978ae5e76e6191b90e", + "192ea806b6a54776bb5d251e07279ff5", + "19ab873b9f8e48a2b8bee75a8f51dac5", + "19e682e07ba940c4ad6bf4020c9611cf", + "19ee740385224ed5973c56a2d093b244", + "1b3c47892a6a4c159e8177a77fbca849", + "1b431de4f3a44c05b86075731f554e12", + "1bb1ec183610470bb39fc92ca294fcc4", + "1bc2167f94eb46f89c495b211f1ca6bd", + "1c17b2f70d01466fab53b36b4a1dee56", + "1c7111a347ae4e9b8849137dff02a7ec", + "1c7463b3fd974e6caa7738fd655dd67c", + "1cbbfeafe529449e9439d42cf6a4f6a5", + "1cc6b0ac2b39467a8236732844ad6f00", + "1d080b2130634a53b5bb4cddba0b5e68", + "1d2afec29bd540fa9ec97273d8290ff8", + "1d31b40ca23e4befad9330af3ce55e7f", + "1d8c704a59824ba7953611acf126b91d", + "1d90e5d43da6416faf8ddf548e6a8e3c", + "1e0c8077e798494083d43a8265864695", + "1e408e66743e48e99baae1b4976af0ec", + "1e68ec841ee444e4be1f64036bf45a6d", + "1e9d34fb91e64272a95409ced5df8b95", + "1e9d5fe826dc495b806daad2a654bc36", + "1ea6f98520414bfeb15899bf5603ea3d", + "1eb3093709b34409b821318cfc9508be", + "1edc19b35bcd474eab1e632343a05086", + "1ee6baabf87845489c61f280f2d32bc0", + "1efa727dc08447409b2c2edc3c5d7ee2", + "1f4ed3a88f0a4466afc575d009da0da9", + "1f79e428877c43898855be6f8a3b6a31", + "2079d8ff134b40838866f0f94cc0aac1", + "208adde332a340c7836bb39bddbf50eb", + "21de7c76fab24e908d689fcd201f19e9", + "21ebb73fd71740b787a615de2493996f", + "227b2911c563440cbdd3219426f57541", + "2284c59842a044fe8aaaf07d532e8040", + "22996e77e8144dcfb69d1834456fa3e6", + "229dea763641434f9a67e5376515869d", + "22c2654b3b7a4869a99c8c0846130836", + "22d3a8426c964dcebb822a8a7edaeee7", + "22e9b7f2d288407fb3426a1863044424", + "23de624c37164ba58c4bd029c0b110df", + "24aae2238a34448e94229d6e0480e712", + "253182e4767c4a89bd8e92e17f070daf", + "2539ffc65a0e4bff8d62602dc2822e05", + "25b3167be5f44d658a42b592800e55a5", + "25ccdb556b374b71a4490ce60d71e7e5", + "25cd0ca8a9894728bfdb56ff4eac27a9", + "2628134d38354e6e92b907ef1fc573e1", + "269dd661b805492d8c39f211f6c913e7", + "26a3ac1f44384e73a445d46ccd7de7f9", + "26a4c3cb7336460a82991ee30b3a79e3", + "26b376f07a0243288eab8a9207e676a8", + "2702739437584063ab066fe3f789284b", + "27497ce08d4f4491a2a77d36bfb6942f", + "279e835b03614df7bf647a50c551376b", + "27bbdf457891487d9e2b68eccf7e509d", + "281ca040f4544361b693c8f6a58fd3a4", + "2821c35b945042778b17e3e3ec6d154f", + "283635196d344c159fa391e0c2aefa2b", + "284b8d38609747bfbe54f65b6a5d69db", + "2875c5ac55f84381a15960372dc7095a", + "287e9bace6a84b40b071a6477572e00f", + "28b2f2562a5b48209d4a9d94327b73e3", + "28f780cb153c4c30a75118b7dde96983", + "29339dff128a445aa8efeae5972188e1", + "29543294d9b3482f83735c9a5cb4fa45", + "295eb1d547844f13b8dc09a5168befc9", + "2991e1205f04409e8c99b4e456ac707d", + "2997e1781e8442a18703e85506036791", + "29c6fe8ea88e4478be6b598d8a5ef6c6", + "29f0d03a4f4d4f93b04221dabc5e1db0", + "2a70b10530984320bccf3ef008fd1cd1", + "2aa87208216441ce82a20604147f26ab", + "2ab58daa7a6748328b1ab5b005a9beb6", + "2adbb2dac6f347f68af306f5e9d2afd9", + "2b0ce45d9aa54d26ba1e3f3617ec227c", + "2c2e94745cae4efa9f3a5ec661c2a097", + "2cb35105af2d4424aa5797a49b2b60b1", + "2d0aadd6cbcc4fda8017d2f5d0dd6912", + "2d1a858af51d48bf93fb14bc4d503a2f", + "2d909e7caf234f12a02d2a180048b481", + "2db92c4f885344818457d332820f895a", + "2dc1b18992c544faaf2d277ad9ebbaab", + "2e2ca125e3d94057b0d8e9ea487d8347", + "2e34f38442004798aab04990a2fdea41", + "2e4dc3e02a8c486da1e0392fcf7cefc6", + "2e9544b2ee5944ae95929ae45aa85ad1", + "2f4251e09c5149a18a9059528a6a8881", + "2f61fb9f9c0142e98998a13ebb6c55d6", + "2f90c17cfb9f41cfbeb43ce044527382", + "2fde0d598a1a442fa998e6b0f40c4d3d", + "2ff03738d4ea4ba69cb60468653a335a", + "302bcca947454b5991854be58c43c9a6", + "304e5643e818498baf8200db30104c4e", + "30bcb5a6d3c847c688f666900fe2c95f", + "3128fb4e35b54f59a3f445d383aa4805", + "316a6943eca14da0bd6185d4a0adf26e", + "316a88b8fd1a441fa7f275d0ffca95a6", + "3219321a0fdf4fe9a09e819a3adcf42c", + "3244a271bbc34cdbade355d497519ea2", + "324ba93ac6d64d8680fe10a784b7cfc8", + "329fe54dd1234073a694a53b0b53e1f7", + "32b134126af244248cfb73766ac90668", + "32c6a113445948d8b4300aecd8173443", + "32c899669f3244d48fe30f66b72873d3", + "34466224b3794c5c9a8c7a60f2846999", + "34495f0604764173a2a9296ca2020507", + "34b68c94497d4d9e8e4cde1357867000", + "34c147d48f16414c8f236def80e04c55", + "3518d7623e0e47568294ffc1ed6c9e7b", + "352ee9e5013045a1aa78fe810d3b8451", + "3533b25afd9e4203867e13f20339a6c2", + "35cad42d2a3b4407b27b026b379aa635", + "364d2e7d10d64e2587c0728e5a7067a1", + "3679cc16bed34dde9242a991c128fe3f", + "370034c383344ef59aaacddca1699939", + "370134b1c2d746bbbadd36717d8fc756", + "37107a57570e47b4ad7649f8db0346ca", + "372bd53fae794188b25b1f374a7ad45e", + "375cf3f8e76145a5b1f36e35dae79bbe", + "3774ebc8c4694ca4974f8688c52874c7", + "3780e18615964203be4f23dfbf1de9eb", + "37a093934ed24526ad39a09ecab50dfc", + "37fc1e580409419094f8bfc4f95e17ce", + "382eb35f093b477eac62ea70f078ec30", + "388b64796cb041bf9bf9f669f7031a1e", + "389b9456b5c042878189b44b733e7790", + "3908a470fd454e208e8788bf96b6096d", + "395e1461eaa24969a8e14593085e3b51", + "399b26640f7b4b79905b303618e41add", + "39a0d62d8727411e87c44db83c1f5eec", + "3a0f02a58de640d8a7720d952a537d43", + "3a2384cf61c94e8c9032d4178e330b68", + "3a38385173ab4eeb81de68b908ae1b6c", + "3a73c8b63baf498397a5fe4db9b285f2", + "3ab277f3bf7c43edba70a1108fe03091", + "3ac1abe1c4374437bd75604df0df85c2", + "3ac2c51dd4a040e9a0b1d7d7ee293888", + "3ad87e56864644b6a5e2588976e99c66", + "3aeaa073b55c40a6ba2fd7801a761e9b", + "3af1bf7623d44d85af1a1cdc6bc753f4", + "3b16b21feb934ebc876ed38a90517f4a", + "3b209a32eab14acaa88c0832c3608d9f", + "3b37f8dec3b944a4a584827cc17d1a05", + "3b4547c99e8a4447ba32f90b193090c2", + "3bbf0238494f41a7a961cf75c29947b5", + "3c2fafe3cd45462fbcc22bc4c74131de", + "3c4734161add46818a557ad60d402617", + "3c4df33cd3614425a9fcd5829ba3f58a", + "3c5f9b0906a54caf84ff569e87ddcf04", + "3cb294feebf1462bb9e4b8e75c6bda11", + "3d393990d0d24d1f9e6eba9b8bd538b8", + "3d419a217af842a6af14123f08cf1762", + "3d882f00a1704df39d986cb70a93b8a2", + "3e1821ae298f44019e31c9eb40780ab4", + "3eda77a1e8b7488fb74d0d43806e5b56", + "3eed63b2092a47e4ae5fd8fff1204200", + "3facacdbe8fe495ca6ef8009733eff29", + "3fbe58ed6301457eafc3878be3fa31fd", + "3fd8527f3d6a444394607b59a8b60513", + "3ff0a381f5a44b4ea461060bcf302bb7", + "405d1563a7bc4e93b9eb7cce50ffb0af", + "406b0b5e3db2421b934eafea5b84bfe5", + "4117622e1cef4651a6a914dc8a31737e", + "4197e6d88d1c46f1b3c02471e5572f65", + "41cdb86025bd4153859a8372c1fdd9d1", + "41eb75e8aea74a0096c1ab757e1b7407", + "420d6c519a314950a61c25db73e3bfa4", + "421913c1f8634b4e9e16f2bb6265e197", + "4232ebbe65334cf3beb4929dd9831af6", + "42b3dec6293b4a78bd767d88d2cdeaca", + "4301caaef6fa46048123d34d3fe9044e", + "435fbd5ede7046ddb501af98576af00a", + "43c180b04bd34306863a3421a8b2cc13", + "445f5a55ed0c4e18b7138ec1cc112a3c", + "44b493177da44cc8b008157ffac40c8e", + "44d22eeaf4884364882414dc1aa345a2", + "44dde028796d44dd82c1656088546fbe", + "44e725e8725149e2aef372416f8c2218", + "455ce35d692c4024bbb661da1c873ba4", + "45b6b4ef5485447c9661195d1507ed55", + "460bdb4ea1574738bd91a759d20f2e77", + "465d41c2ef1647d5a5a4dccd977266f3", + "46e994b537be4a0880159e711da2e311", + "46ed695a2a214697afc86118a79b65d9", + "47593bf43c3d4cf4af8b42309cee7154", + "4791bcc7fa7d4c0fa13e1387325a0f9e", + "47ad6a2c37a049aeabd3b514f92f3206", + "47d7b7fafdb84bdf82d6fa145aef59b1", + "485b9b3c44214745badc1a253fa51feb", + "48a6397b40494b1b898337b5282ae50f", + "4906abf273f7476aa73bd9faf9a67711", + "4908f6e00c284da79312d9cbf31e4390", + "4922ca54dae94ea9a560ba2a7d8aeedb", + "493854ddbda4452aad4ab89bd5516fa3", + "49429ed3f0f54f31a5b8d79bf431a255", + "49465ea4612443479cee6f57bd3a14f6", + "495abcec12fd4d62942bffb577818774", + "496bb100aeea4af7939e20a0753398d6", + "498b1e8ac047484a83b5eb94afe5a06b", + "49f6bb3ee5324c23821189a54729d56b", + "4aef03eeb8eb4abe83decd95bb11875f", + "4b0c8fe30d6a437592053261e214514d", + "4b399633dc5840e4b3f2518f0008bc79", + "4c0a1484d803465a8e9d82e62f4b9aec", + "4c1acb47999f429784dde34c7a1001e2", + "4c63fa9796294bc89a594fa85c0b820b", + "4ca7de721819440e85d62eed9dad6bac", + "4d1d98e0491c484f8c6c173248ed12cf", + "4d2b4571dff549e6a2ee55299b2439fc", + "4d52c948ad824f02a2c0199e0a312e13", + "4e0e919086724ef485040f4a0f84a870", + "4e987aab50704461b0ae283b6d5efc0e", + "4e9904d4635a4f8493cc4c1eee14aa44", + "4f3e33d27e9a444da6ecb2223ee7ca1e", + "4fc07b257bf041138f935e1b4f698805", + "502e7499c92b415e916b2a6497c83607", + "5032f3a498204078b63e6007f3370123", + "50bc5821ab6146f196f87d92b75cc82b", + "50d666a8092947d7b8fd7c1a5106b1ba", + "50dfca2fea9c42698cb3f3ca26446383", + "5124d7dc7b6746219251ea30cb4dc249", + "514be3c5b94e4b61a1502236298438bb", + "518257619c86454d87b57e592ad982a5", + "5187b4cca0f0494f90a395cf07c7af70", + "51f244383b1d4373b6c60f74484571e3", + "51f4e45d823846c9a3cddbdc25ec3838", + "52f5da990261406582eb95507405450e", + "530e676257a94bcd911d83d8e6fbcb0d", + "538beeae916546fea1dbfe69c9fce2d2", + "540c7d8bee8844b3a07da33f3c536d03", + "54267caf8ee34238a06744035757df58", + "54321522124646a88c41232da5bb1479", + "545fefda9b81412d808b6486c375cc9f", + "54f0204685404a4b8888eba7bc64fe45", + "551c131659284e1abf762cdeec0592d9", + "56c4ce98b44948109d36f01eeb4f69ab", + "56ec37b5d9e0416eaed19ca5a46bb44b", + "56ed71e7439246a687764844e685591e", + "573a97d014ae426b9b50f1328ebe33b9", + "57d57e28bfa84de8825f9d58d6aa2966", + "58266825a4e84b8f9f73d4492adfe5a2", + "582f60f15e2042e8b2767379032c0dc8", + "5832df578fbe4a5980fd21e454b4f362", + "586029f4f592406dab378444463c0e62", + "58626517342b4edfa326280477efced2", + "58707719126349cb916b35b7ae7522d1", + "587da3d3efc846638f05bade9b5af378", + "58a6b88b309848c29bc585d39c2c6e0e", + "58cbfedabbc34258acdeaa045a89842a", + "58cec05d521d427ab89af8a4db3d9165", + "58ec79473573490bb62da6a8c97d0804", + "58f63e51be30404592c8abb65a1cf8a8", + "59510e5772f748e797a6814cd86dc286", + "597a0695f6d6474f83d83dba8aba2033", + "59eb2364df724cc1bba8d31b7416b12e", + "5a2f4e1eee974c66bde5bced90033639", + "5a4acbff6c02445ab139e5746761390c", + "5a682ff43ad946f6b221818b30b66f96", + "5a70186fde6646aebdf0d51230bf5b99", + "5a80ca2ba05b46aa92bb4be7ed0921ab", + "5b11baac2ed74453a3d16322e989deed", + "5b19eb3d8c0340569925a3f8d2214700", + "5b2d3dc371a54d4cb737672433876b92", + "5b3d0f48c560425999492cef55d87a84", + "5b5ea6b411c1479996dc65d49e610577", + "5b6bc342244545efbe15ee86801c3a69", + "5b7b5d504f9b4afa918158cb14808d6e", + "5b868cb8c6b543ccb53e2a4c3f7078e1", + "5b9e6deddbe24f288a389fe51ea5e8e7", + "5c0d00334da5438b9fc92d3907f48bc7", + "5c1f7185d9564a5aa379794b1c6b8c4b", + "5c23b7ce08424dff841a8a681bd5d581", + "5c45faf0d43e42f9bdddca68b25b9c81", + "5c9488aafc994193bd7c261768f4c5c3", + "5ca7656a237d4c83bc22e2ff9c0fd47d", + "5ced348d77e24bcb968cfa9d08368d6f", + "5d105439972e4abda12ec47923f08b08", + "5da7b3155bab4a8ab46ae88be5b8de0a", + "5dd33d2bb06f472294af1a9663b7f8ce", + "5ddb583db95848cdbffc25eb391b4b03", + "5df247b269d54fbc84b1aa1487ab1b2e", + "5e2f37f552a74e21b5ac1f7399a537a4", + "5e53a114dab046ca91a54f297550ca6f", + "5e5f75b2e6d04ae5b6187d6fd667df32", + "5e78ef097746475e949c5fc524b8e3a7", + "5eb869d8f5fa485590ec6449bf85674c", + "5edf1a5e580944b09a68ab24933d2b0f", + "5eff65278242469a97eaaad1b7a1c65a", + "5f6ccc6b8eb642afb1aa029af6d46d81", + "5f8bfcb6d025413a8d25d820851886aa", + "5fb60cb2c82a44fc9223b09893252dcc", + "5fd19e86fe6e463f97965eefb37528d5", + "5ffdeffaf53c4ad6b40dfb05cf170aa4", + "60af0f9fbe9d428fb8e5961e5e738945", + "611393db6e8141ef8487d1a21a98983e", + "61350d3fcd054420a991b81e665911f0", + "617401ec3ace4d06a961379194038fae", + "61d299863e3d42bbaa7590cc329a07c6", + "61fc93e9fbea4d36bbf248f0d3e8b234", + "620ad35e684242e9ac3b1ec0bbeb347c", + "620b299114ad438fae5b68e1ad75fd23", + "62581a8d62bb47bb84659e1d92247f9c", + "628f951f760c49db92fa0be3bd504a6f", + "629ab46217a8461392c25d7fd6c68e27", + "632e33f3d3a24027a1bd076950ab1d2f", + "63430ce9964f4f6aafa3818e1230f07f", + "6358776483d94558a6b0960c9893dc20", + "6375093fca9e40faab037c2644bedbde", + "63b10c41167a4dc9bc9781328f60d936", + "63b30556d54447a3acfe95b89f82b4fe", + "63b8ca545dc34c63ba9dbfa179c41855", + "63bea2d2705647cc88ce4d071d7360e6", + "63d47d7efa6846f0a3d9efb3b7aa6719", + "63e88ca096234260999dac5fae9e18b9", + "6438f141157e4bcea269f5baa2536d3b", + "6466f0415ab94ba3884cfa11af9d6fe0", + "6476e59810b242268af1a69e4b4f0ad1", + "6489a86a984d4b39acb7825ec69b8bac", + "64a7bbb4a32c4150a34a949d5c4e45f9", + "650b0728e2274fc3840870c3ce30b6f2", + "652875e83c374812aa9c546a22df3e91", + "65381fbfdfd0405fac54f0bf92d6637f", + "657d5a01e0ff4dacac3aee240e8ea804", + "659b082f547e49c7a26936e634432bdd", + "65a2ea1a30f04d1b910becf2ce51df1d", + "65dc74ea3cb34563988c642b8c3d7b43", + "65dfd2b9a3094266857cc7708abde2fa", + "65ecd70cb0b941d6afad8eae69c00d27", + "6679e3250c594d27a17612c27e507946", + "66a154b464124fe397537c4fd02207e9", + "6700ffe6c9c5488ab22ad15ff36b4c9c", + "6812efd81f85478ea490bac1d51bdba2", + "68159fc2a57245dfa9d329b61389d724", + "681a5c1998fb4380841179313d004c96", + "683e06500123484d81a9188d6da202b0", + "68c5629e34444b888917987f8d1e74ce", + "698629a9d25441bdbfc3dca21b15fa81", + "69ae7517ee19437e803b7c34ab77c549", + "69b68385f9e24bb79e3f5a6ba787d15b", + "69c6dcd7ff9242ce962065a0367e47da", + "69df1262fac3438693ea513aaf93431d", + "69f948bd64c0461594e3216a5d93cdde", + "69f9bb4c0f7c452d9f6098c73c0c5caa", + "6a168a216bdb44f28e902d69b2c90610", + "6a4dbff78a564b09b1f6372918dd763f", + "6acfad06e5d24a2eb6d82751ad86bda6", + "6af2f7a5adca4c7dbc21db61ff3b5f52", + "6b1f2b6d0df440e3b114646299883ed8", + "6b6c3e386b334f5caf53386ffc86740e", + "6ba5b5330da546f3ac3e67a568a695f9", + "6bb9343069be44f1ac3d7c2b121e9fea", + "6bc8c16ceb354bfd88f07efa31af15f5", + "6bd9411854974ab88859d26ad37556d1", + "6c43cd5943d14a4a8da7003ebaa69c54", + "6c7a910eaf0041198d3451152e6697bf", + "6d08beaa4d924e50bf433c063a09ed76", + "6d813963c35042df9cf3911354cbabde", + "6d889381d3a344299c07d30fb278f69c", + "6de7860eeffd4e86a8ef1bab237bdb48", + "6defeda2908941ceb3dc48035326fc28", + "6e0b32cdbbd5482582b4d262bf008ec6", + "6e3889fe85bf4d6298c379fcd316c209", + "6e4e264cd33640aab8ff33c560ed927d", + "6e674a614daa42859a31fe3cc8d99c46", + "6e6acf91c68643a29701aa57aab82a62", + "6e91dc8b0deb4710be57c6d4727d0b40", + "6ec78ac93e124bfc8ee195179fdabaf7", + "6eeb5280206742ecaa861952a7e3866f", + "6f08ab70e92442af9912c4de78dc7c97", + "6f6672429f9e4517a2c4f4bd1bb3f7d2", + "6fba200c45844a47aa887bc38e8f98be", + "6ffafb1c50a64103a6e9f6e0f33dde76", + "704a6e9382194278a2aa9c8333e75478", + "705334c04d0249b784818b8b69488d10", + "70e67cc2eef74d2c9a66b1e261c487dc", + "70f40ccff428420092c65d15c86d370b", + "70f622718a994f93940f9869db5e3181", + "70f9a54d5c04408db09a74bddadab7ac", + "70fcb852b4e941da9515def6cc5d1df1", + "7103b50713374cc788e1a4041b99e36d", + "710f2bcc4f03432ba2869aaa0866a390", + "7153fd3a958945e28467b51c49f52d42", + "7163e106a7da4ccd99f4aae89e34968c", + "71b2d418dc4b432d821fe5722975f4ed", + "71b3b53b9b734f4bbdb4a869af8ef0b6", + "722fc27ae81b4761ae0806d4ba4b0d1a", + "7268f326b98d4c4ebd26db986f601db1", + "7286017611b94c5fa5a6d030afbd4cf8", + "728923d8e4e14795ba3dab100a76cc29", + "729525a6ba18456d932bb19f02161810", + "729a89bc54f0415ea6a6ec9888bd4d93", + "729fb62998de48aca19f043e4dc15926", + "72bb405f628e42928b0a8d877ebebf6b", + "72dc04903727457fa58ee2fb5fb86d78", + "72f05cad35fa4be6a6ebf888557dfcf9", + "7335c7110d5b404cbd1164159339bd99", + "7347710049cb4e849a9cb2909566b235", + "734fb9fabc4742bdbf8c9fd69ad4fb6d", + "73c10f94dd624275b28604327795ca38", + "73cfcd309cd54c2bbd89f6ff56fcd93d", + "73f3ab6a133744dbab6ad6d2e2e47a2d", + "7418fc90317e4d5eb3141b1afce10c36", + "7419303fb8354397af94d49607d4c114", + "742590624b17455799ade7dc7816900a", + "744a5d8ff7224e5a92fc34f742ca7170", + "744fe4fbbb524e00935b7717fc0a247e", + "74902b4848064559be3c5e88a1db7ddf", + "74ad63fd6a7e4d928e43539b7afad76f", + "74b5434cbefb43da859f913673425a97", + "74b6f8aec20f4f4bb30359b7176ca80d", + "74d4f118cdf143d59dee516eb4a308f0", + "7532c2b457ce40f19d52f35e48103e6a", + "7591ce2a8a054700a3affaa07095d255", + "75a4e9d20d5244e2a4618e1deab5349a", + "75a679bf96f847459afda3257d7b8ce0", + "7617c6ae063c44949c8b8e7d32c16f03", + "763199de04854e9dbc2a424dbf52cb0f", + "7662835fb9104c9ba9342f0850171b29", + "7662d6ef01504e14b37215f4a3e89598", + "76bb0780eb57440d880fabb2603455f9", + "76ffd9ca5a704464825dbaa38ccbd721", + "7701a47487a64e2caf163fe2bdb0700e", + "7726499076694916b81ffe24fb79caa7", + "7726d69ada1f4e49b9bff590bc96ace3", + "772ca974b68e40b59f7a8aac9f31d72c", + "77505f6f975b408d99d6b7726af9629b", + "775406e8f61b47dea6189b3d84ff7ab5", + "77a96a36f1e04deb86ef7c44b60b9b10", + "781f0e73ae834913a18c32be533eda51", + "7828754e1bec4d93b3393886b965e22a", + "787890be36934e73996a7f01a6f94861", + "78be8ddf599c4f7dacfcb7a61866aea3", + "78e6283e229843cca1fa451a8f7029ed", + "7900ba02eb3a4f66b6080c32659c3e39", + "79019a54869b404ba3145952e339f7e7", + "799d59c579de4d00844b24aeb05d26cc", + "79b684bf7faf443ba89d0856bdffa7d1", + "79cb97d16ef2481a808ba2ff99f5a355", + "7a40263f6ed04c9e8ea14180656199e3", + "7a4af5793e074ac48652661163dc5776", + "7a936e70d5f24a549e397a3837e1ba4e", + "7b15de4c6f4b4888af77670c3ea11869", + "7b1bfd1ce21748d986f3212dc5f17232", + "7b42f6159c7b41d79319938040045381", + "7b58ddb3b36d421289eeb72a89cefb5d", + "7b7429dda11b4a988078cfca21dff292", + "7b90fbbd2cda49008ededdd530712914", + "7c101afddad0459bb4dff2c4488ad078", + "7c57f7a56f5a4029bc9bd0446eb75d74", + "7c69c2d0e0014d2dace902754e080aed", + "7c9a3562c4014d42b6b5e6b95f739b26", + "7d06eb2558cb45d5bf8306cb0759bb9a", + "7dbd2e75ae5f48f897e295d14f43fbc3", + "7df8be4c8c594e6e8e4a02b0d500dc82", + "7e43a31b7f844e76a1841e6c4b53e1bc", + "7e597564f8b245379bfab38c651fe4b8", + "7e9b4294d8fe447d871fb29c94237e35", + "7ea29f3e7f1b49848f43bbf52c76c37e", + "7ed136cc0e3541de959f3dc70aff700e", + "7f5103759fcd4425babf39d933f00120", + "7f6fedf7969f4f7b91dd7e7cd7466639", + "7f7d27f2f9b94d01b9d87325e400567a", + "7fa2b5e5ced549e1aec5d819d6d2f89b", + "7fd262ae7d5d4dbb849fecd60941747b", + "7fdabd43753f4083a47ef530130491a5", + "7fefdf29696b4ecd90ceca0ecd4cf0e1", + "81d5fed5544943f2945b00d4029296e4", + "820a79147a404d2bbc13933563aabf9b", + "8237fd0c02b84bf9bca6f825479f313f", + "8244f5b274c94e3586360049b309a043", + "82b5093102b04d4c97c13de1605f6c26", + "82d97f03630d4123974c8efb257b61b7", + "830c09f450e54f50883471b14e16a532", + "8317e53620c442a6b6beb41e8a9e0855", + "831d2471fa22413aaca1495cb30cde2d", + "838812c78b634fa998ece4cc6f8aa0e7", + "83b86e94af8a4ad8a670105b7e06ec05", + "83c440528f694037be11f965786e2dbb", + "844e30e091924ab58b9c23576c855b91", + "8482c1b552684ee99d0c34f7f7f8d0f0", + "849c5daef11a4734a31098c69908ad75", + "84e7131582044fbd8de1c5fdef38f5a9", + "850bf56c4af443ed9a81327daf0dc438", + "85801358c4f54f96923503782005d724", + "8580f87f07124491a2f96352e26a5a01", + "85c158a5e0a54c54848c225c13986e29", + "860f8d509c3e4309b89646fab1b84e80", + "86304c832c484d4b9655056fb6ae1801", + "864a71caead2489ca3f585338be85b7d", + "867b741fe4ad40ecaeac1d231ebdc8d2", + "8686fb33c1cd4db0b56ca6917f613c27", + "8690499fab8c43a8b48cb48feae28453", + "86e0e8bd77e74fb8bfc482ce128df037", + "86fda9540c1c43b3b73f35303498e39c", + "874888961bc04a02b3ddb67777286a67", + "876924d1050c40a290ad0131e3512df5", + "87d1eba5ba704732a2e88a1b21e9628d", + "87f70cda8bf54645922f6bcd69870a8f", + "8848f672727b4178a4d40f78a4055d1b", + "88f1b57a429645bd92d2856ba56bbc8e", + "890fa2a033fd40b8b967a3ac87663f90", + "894145aa78c24d8ca2ac910cf83c7aac", + "894a2bfd62f64594a2376fd59039b30c", + "89a1556ee12f4700b62517124707be7e", + "8a19de209cac479bbcd975210a089591", + "8a53d3789e12438b9b86369189925a1a", + "8abd8314eb7e44f0ab8334e18bc02a02", + "8b3c50fe5323479883e3c3fd3462fcc9", + "8ba5e02e91ad47ca9ac8c2e2ea705207", + "8ba5fdc94f4b40119a6829a06f0927b3", + "8bad2015b18841c19be2dd1bca311a6d", + "8bde375107214755818909b3063b912f", + "8bf4eba9ca91408c9083bd0bb26467d0", + "8bf74af344f94202b36bad613366d210", + "8d0a86910c5b4e9b95150ac7a5c2cd82", + "8d6097df2caf420083629d5ad5beb3fe", + "8d9b2215710749ae8d28b3b02e3d413e", + "8daefc27b6b548859f0f5cf84aa88274", + "8db4506fbd5a427e9ec120b638253069", + "8dc33f72d84b46069973e9002ef05749", + "8e2e7b5afc564b6fa04c96dd786c83ec", + "8e86c724623d4d88a16e0ec82d0c7241", + "8e9283e3fbec4f65b3670da05e9e28f6", + "8f0b5a745d634036b4869afe9d6ef214", + "8f3268e21a1b42c6bc85823dc821e736", + "8f4f227ddd354cf5a96b566606081ea1", + "8fca81a5a6564f068f4d418a322e9cf7", + "8fcd4b9e5afb414c94a8e0290e0305e6", + "9038f2b3470a4bc0a3ec7d550addaafe", + "9052fa5c275745c0943be965200557e8", + "906766c4732540ae8c293ee975a6f8fa", + "90b4038578ae45d1a68a008b2e5283e7", + "9127a30100654aa3aa5d71017306155e", + "9137173895824268ac52b140a2a0b60e", + "917bf3d735e94729870bfcf9cdbac574", + "9194e843cfae4977afe4c37f7edf8913", + "92027c5c27694541aa4d964fe3a9cf50", + "9210a3c7b6a24a19865dc3a2947cffe0", + "922cbf555e3540a2986a331c7e4b5323", + "924098674331485b97854c28a94d1fe7", + "92647f9008c8492885b2a35bfd369ee1", + "928796a07c26435e8a1dc77b7e95e36e", + "92ab534daa5d4e53af8d2bd6ee69d102", + "92bda0763e0e4207a0689b39da744ec4", + "92f3934131fd4390a6375f8d8e949980", + "933806d2e01741f9802523969d5633a3", + "9355d6aab6134612967a35cf1b8ce14f", + "93d4dcba08e042d4a9e23c54167f96ab", + "945c754f52e54875a911724ad1808932", + "945fc3c74d234ac5be141ce5a502eb42", + "947dd998c1f049028d05f876265cad86", + "94ccaea9c662452480d6482a3e51417f", + "94f5dec39f31460db06bd6500eee37e2", + "9517a5dff30b42018ee1b07fa5eda9b2", + "952c43e739b04104a10d2abb1ed907aa", + "952d716d8531445994bc3e917929bea8", + "9598689cb566408889b10f756252197f", + "95a1f80f924142ccbc386c8841db4316", + "95feda14d90249b5b984249b9323d25d", + "962f6704bb39417cbc75039e51c591cf", + "9637b84cb4da441e87dc893a660041d3", + "96a0666936df43d2ba06e51a0c34f3c6", + "96f238f37c8740d497a60f095089ca5f", + "972fc0bb13ba4ddfb87c74ff0ec03c7c", + "97371756ee97448190ee3aa2605392c2", + "976676e44d4a4a839e878f98b27232ae", + "97bcefcae10d4fbca9cde6a2cac7574c", + "97c600b9118d4751bf160317cab00959", + "985fbe44ea8844539e65929558ca6eac", + "987d1cd4836d4dbc9a873c8a96e5872c", + "988c8e347f794df2810c1da505a97c23", + "98a2c3031a8744309c77d8214ac75acd", + "9920418e9f8b423da9ba5db64337942b", + "99661ac610b54c258281dbf517a69017", + "998273600f034277ae32ea4253921bb3", + "99bf5bbb623d4a9e9d166de75f564ea1", + "99c07d3eff3e44128c1fd2a608e85daa", + "99e6b074c8264ce1b862f4f63597f334", + "99ecdcb6482046dcbc944d97dc7359a7", + "9a8a554c62034a16a11ccb15ff805059", + "9aad2e34f34a414b93378d3adf39f6b7", + "9b37142b8ce24e3ea055aa103900d0c7", + "9b6e92ee44c848c6b9f3c09191463aa0", + "9bd1867f522f4e3eaa1057bf244712cc", + "9c180c575f9b4a6587a92f3cc13c79cf", + "9cbdfbe6eec44b879eb02edbe2ff331b", + "9ce1c2ed92ef415e880f60d63be185c0", + "9cf54b32358246dcb8a0e9f530561359", + "9d6b465feff4412e89cb8d2bf9d9a632", + "9d7a627887f54c3f89d4c8d52a6680e5", + "9df5b310ccc84d1e87ac07dc14e73321", + "9e128246c3d7403ca0115e2a34743c02", + "9e17e933e62f434798487280bdcb32ef", + "9e6f038c14eb42d7b8b538324fc7ec9d", + "9ec7430a814446c1864b814a04af63a7", + "9f606f9f8f0f4b52954303ee543731a2", + "9f79ebbd1fcb419b986176102d2fb88e", + "9f89a56cc08f4eee94a9bf543af28440", + "a0f24ed11367450bb405559088a99fb0", + "a14281737e554a05b3a69e37b74aed85", + "a142fc31861d445fa0d0ce24a51d75ce", + "a1af403ff7a948a2a4c649dcbf84a30c", + "a1b2e4df75c344079518b2560140be4a", + "a1fdcbb566764a1ab5ff4d1c884367e9", + "a28c1756bb224d03bf217e5065c20144", + "a2ab9f8e3fe9493295d8deed66c04673", + "a2be513e77ca4539a92e556a1a8dd2ea", + "a3402ba226ce4b93a83f43222674ab67", + "a344791f0172469eafceada4eb3beb9e", + "a3e07de8dff14e2ea9f88952ceddf008", + "a42d9c8a0dd246cf963a24a7c34197e5", + "a46268a4dfde4c51bb8c62283a675afb", + "a4a4dd986fdd4c78a0c61221a6d23048", + "a4b88518b60f4d84b4e1770853c3f55a", + "a4ca9ce9e007491994d3fe407f96993c", + "a5016b06bea84d6c87b5858bb139f6e9", + "a557671b6302434e817d60d3d4d1f16c", + "a56fa6c7fe01440b906164d0d7d72681", + "a611490e8586420daedd397f78550b68", + "a6757d08111a4e5b833c0c5bb26829d9", + "a68dca6af6ae45ae84749b91a4581bdb", + "a68f9a9e401b462ba46460a94c502e4d", + "a6dffbf16190439fac6a81dc66e0cdeb", + "a77df7c09a6348278d17c09ac8290c90", + "a818d1a0eb0c43d588b0aa2f7e20a3ff", + "a84e0bdbaf2a40859c71372b0eabb45e", + "a898b38fb525466ea18ba2e4d6489de3", + "a8f63f4bab7e4f7aa3b3460fb4497751", + "a90326ecea4b4da3a5dda1160b92b41d", + "a91d2960ed1f4620ad2de76c0e4f6cbe", + "a932049856bc4b008992480b8acb4b3b", + "a97897db615448aaa9aee60fa65787d6", + "a9901fc1fe374e0185bdd2d079512713", + "a9a1fe43f3384d9685b8317b0655558f", + "a9b95a5d279a4b908e664c1e7626f450", + "a9ceec6c598b4180a7b36cb52d127595", + "a9dd174158f5498abcf937b3a2820a49", + "aa58e09a2f354b2abaf502143cfa3a6d", + "aa907fd398294276b3095157bbb06f00", + "aad0d28a610d46dfa7725111023027be", + "ab08a552501e41aaa9be333574f63d74", + "ab0c1b0e99a9455388fb6c523ba5887d", + "ab31f7b3b7514ea089156014f302fd2e", + "ab76245b148b426a9ac5331ab2370133", + "ab7bd4625dff4749b7edcf60bbcdbac2", + "ab8d180b2c0c4ebfabe84be5afde21e3", + "abee805f51c640799a27162046e4c89d", + "ac3dd8dbdc964344bf40bf0b4b751f72", + "ac5d6feb3cd744a09e983a24656e5245", + "ac6135b423b84add9504b9cd7fff52a9", + "ac623ddada464e5ba661704ffc93f51e", + "ac65fd3e1a6e4d6f8588efff268acd93", + "ac9df305c2454bd091533bd41fec56eb", + "acafbe2adc81470795428d6079f7edf3", + "ad2a51fa27de40b49062b05035a11a6c", + "ad494bd0c34049469d9b3c8fa20cf585", + "ad62ce2b77a5431db48b8d74f2a7202d", + "ad8bb461e8584d698e43e49164c0365f", + "adbb30f256954c87954b476c240d23b6", + "adf48702d0df49a6bfebc1e17ebccdbf", + "ae32f7975621402c9fa32528a75bb035", + "ae59fb6e34974b79a1baa7c9a9d3dbe0", + "afc76ace13a14193b25437d4d9011621", + "afe99d79c2e04fcbbaea5cef983d8e3c", + "b00f791866a44df7bcc27f517b5b97aa", + "b0430c999de641c288d175d16d897d27", + "b07a0f4a94064f5899f80d6d9369e630", + "b0a4293cfe274a20834cb1990d669280", + "b0ad736b45e14c67b9c278bb25eaa364", + "b0ae066aae204450a51f1209bc3419ef", + "b0bac26cd5f04d10a5103a0568897862", + "b0bf05a7e5994618870fdfd083fc2e2f", + "b0f007901e5844b8b7c4e5cf49faabb9", + "b1337481ff7f4b6ebbd2f85b8e8af85a", + "b1628a4c3b69469ab56d119f64fd82ad", + "b167a7f60cbc4b1eb87fb8cca7a618a8", + "b194904711984282b1f6e9e817d795d6", + "b19a83b81062450abd0b43f642241c77", + "b2060c525bac466aaf30ba7be4184472", + "b22d5d95571c4c3c8d40923fa8f0e717", + "b2550bea2d394fb0b077cd43c5a9df13", + "b282150879374c309d6d170beeaee292", + "b2cb7025869b4ccbab1126d1394d2bdf", + "b2d576863b754305b13f0d2775056078", + "b2fe6cb1f04d4abc8feb6a7edcf4d3d3", + "b30a2d7c2fa2463aadc7ff4673847715", + "b32be581c67545a182f5fec499a5a158", + "b3fc3176e9394cfb8f3bf61fa3650433", + "b41ec3792b934f25b0d4b5abfa66f0a7", + "b43046970de24a12b4300147568409ab", + "b432464cfc0247e3825029215a064b58", + "b4503172cf764f70ac696c3141ca2bca", + "b48781a4a8de45468ef1cab29606899b", + "b48d1016c50e468892ace173ef6d05e0", + "b4d674de42da4636bd3da33932e11e15", + "b4d810926bd84eaf83f26adbf7d0188f", + "b512089cbf0e45fd99624d314ac35622", + "b51a98bf2545471fb933995f8eb4385c", + "b55db82146aa4011b5c36d88d1f7080f", + "b57f221e15b14885bdb4d8b7e73297dc", + "b5a89061cc414994aeec2fd6802afa5c", + "b5b5ca58b44148a2add02bbd8e4ae953", + "b5e78de47b02446195331a96c3f94cab", + "b650432b65944a19ae0fa3bb59cb3949", + "b67f2dc8f40c469aa717de0450828bad", + "b6a004bbfec04551ba49782e102aff70", + "b6bdcf944f1c485089961dd1cfa2c399", + "b6d6ba85c6694d5d81ffc856571f0276", + "b73753d4f6a540cc908d55533764eab6", + "b74475eca8d44a05aa74467b3530c71b", + "b767fa939640460b82f7bb3ec2f94ec5", + "b77dfe3ec9bc416e8bd0e51945e7851c", + "b831376f11644476bb478ddfe63c8f31", + "b84ce897359a4a3aa9e6d070dd9b5448", + "b88214cc7af1409b86ffd68503b585e4", + "b90b3ba4f4f845498ba8a8e08ef57ea9", + "b91ff2b807ff45ac828c69d1840f83fd", + "b93edfe83bf843d49548108eef80f295", + "b950311bc2164c1091291a3e7f773cfc", + "b951125f055048af8234f863f89d70e7", + "b95dfeae91704770bc5d99c86ab42527", + "b980d7f2431e469594bd1eef8b826a9f", + "b985da106d764b1caafe4d35833c2797", + "ba3dd5e983a34d4098351324ed70f059", + "ba738e9f060444bc8ae1548da9b4de19", + "bb1ff784ad254ae1b083732675fc66ae", + "bb20a5fa9506480ca75225eed9c5abe1", + "bb2a51e6ca344b45b3ec757984809bd1", + "bbb2668d61ae4b4896422a8d92b48e31", + "bc3829b5c4fe41ef9166a71dc9b97a24", + "bc3db0b20a8d4c499b28a82430e95d0c", + "bc5b361f727c408c97e12bfbc4d6c3f8", + "bc6c15858b0d4436af32c0b167d9b802", + "bc6c35a4ec0b451facd34309d1fce673", + "bc6dc1f16c7849faa271e55caadc4dd9", + "bc76cd60baf945189f1acca885152776", + "bc7b80dd2bd34c4b90986430eebefea9", + "bcb50a36308b4a25bdd5954e82f6694d", + "bd0fe7034c6848ad8fde4ad1cfaf9f50", + "bd18648d84ce4e52b95effdaf3fc2f2d", + "bd36c1c9ff5d43b29c75023fa76555aa", + "bd4c37f7d9774a309cf0bd9be7d555e2", + "bd533c11907d459481cf19ffa9244ead", + "bd8354f087a740c7bbaacd49dd006818", + "bde91ed9ca604347a104c7fbfca4ec38", + "bdfba122f3454339890aab854df46b16", + "be1a659885f040c6acfc7d0deaa02c2d", + "be3c1cfb54c94329bbdfa6034e6ce8b5", + "be550d994729451e82ed5b885825ba70", + "bea2264dd77a49bf925522a895f436fa", + "bec0091d5fb54e03939081a4b7d18da8", + "bee5e56215524e0db90a36cfb1ed6554", + "bf383619a9fd4df6b236da0eb22289f8", + "bf60d8a9b98345988bca10070fd49948", + "bf8b727a205b4f1a9a1f0ceb72dfaf92", + "bfec0bac17784e978671df2806f66218", + "c09cfd32f39041c9af83cfedc209ab90", + "c0c888a0f5174584bcd3640bea9c16c2", + "c15fb31e79274b1ea73ec0165c34d179", + "c18e8dc8cc114e74845f65dcb3535f7f", + "c1c0add2e212480b8535aa54902068ca", + "c1c3974b5a9d4365a4663cca7cf2be90", + "c1c99067671d43dbb8bb7457d91fc140", + "c1cde2eb38db40c0ad5cc811490b8104", + "c1fc3c1e34b74ef78c9acf01e5301505", + "c2a42194bf0446c7b43a5b9e24b5ee5f", + "c2b22b0ce0834ab5bba0a81b21e890de", + "c2b92df7d6ba4d2bbb6a126e2c4e9c49", + "c2bd10e2eed74674870c055096a9fff6", + "c2c23a53147148da95f5b7881fdece75", + "c32183c9f6f9488aaba89c81ea672268", + "c34116d332434ead91b17c69647a82de", + "c3847e875a29468a8d6ee153a8239626", + "c3f1418e0ce64027886c9aa2951785e6", + "c4a80296a22e4860a110c10b6ce237e0", + "c4bc2c15aa9d441c9610f9483f2347c3", + "c53f2eb1ac3644d6ae4ebc12c4a46956", + "c576bfc259fa4927ba48fd62e3a3f8f8", + "c5867de023104581afcf473f24796e4f", + "c5dcc5e9e5f84063beb5ddcbf50c8356", + "c5f6c170d32e4732b6cb1cb79c6161f4", + "c603757e1db34362891f9bcc95462cf8", + "c60720d6b47745589bedd9e05551f814", + "c6698e957fa945749fc3b9aaedf3adba", + "c69de10fdd594e06bcca714a270c8008", + "c6cd9c2db199437e9850d463e5ba4e53", + "c6f3d53a005547e1bae22db6a3d6afe2", + "c6feb8cebff84fbbbae7182d16d8026a", + "c79d82557725488b83d93899dbd9719a", + "c7b7724aa259426d8a2136b3add94fc0", + "c7da12ea8deb45448e745aca869f12c3", + "c7eb255bf0d74dfdbe0d5a380c53a0f2", + "c7eb78c6be1c43769e9a1920c61911c7", + "c80802e54c89475f9887843f51dcf632", + "c81730681f9d4b5db4b225a8c31123b0", + "c820c89b751242bc893dc976023b6935", + "c8a0312d05d549e2aff7c72ec899c098", + "c9fbb5ecf4e44efc811576749781ea64", + "ca1e81405f7a41679104be449ea316d5", + "ca4f110e7130495d95795cca9bda9d98", + "ca6de369041a43b3a694b56db6db63c2", + "ca945ba5fc97490593d6cc3ff225fc1b", + "ca966d443bb643438e878fd2bc7bcd62", + "caa352932a4d47aebc02c340683fddb7", + "cabe3ffcc61346a9b957852db1d5d0b0", + "caeba102c3df4769b32bb48433642642", + "caf68e4759e14eb1822887fff2df5b5a", + "cb41e950a550484a9dda9fc2532c7eb3", + "cc109fdb15274e44a084fc15ad743521", + "cc418dc586a6417e97b7548f761f6007", + "cce0e415068d4b64b62c5af28369662b", + "cd04533338144279aa583e79d02201ec", + "cd4906bd5fba459dbeed7f6f6c36491b", + "cd59af2dfcb64191b9b1c4de9529a08c", + "cd5f6487999748088ca128ed9617937f", + "cdbd84d964714f3781b3a32918b6176c", + "cdd8e56fe16d49d7912d82661692dd72", + "ce1254ab22bc4d6d929f8266a9b88156", + "ce6d121314a24e5da3f417d68d5f89ca", + "ce85c0a5581343218c705d66ce0e6d99", + "ce9efe3775c14aa2a9845723dfe92c79", + "cf724cce0f9b4763a08d33fe5046e3aa", + "cfda855d88f34977ba7787be012e2729", + "d0247ea03b1b46688d0d05a9de462ed1", + "d029560747de4b67af53bc8917ceee88", + "d0bc143fca514bd2872c5771670db08b", + "d0cf378e25204bdc92b36de88981048a", + "d0dceec9dcce4b2388dc03dd8dff82e8", + "d12a0064abcc4e51904596d27d2a0b2d", + "d1896182e23d4940becb0b72d8596751", + "d190557c8f3644118fb2c90698b5684d", + "d29a92a302054fb4a6a754aa002addd5", + "d2c9f6b98efd4e4094ffdb58f60f8fbf", + "d337ff4890914693aca424290bcd9edb", + "d40b9d0931c14a3e9d9216ce21d739ff", + "d4117c0d8cc64567b02d47db69487522", + "d42ab5191e894c9e9f3dfc8de0861ab9", + "d46c07499bf44938905c7f1cefae56c2", + "d492441d79b248e69a97f85db63db96f", + "d4d7322334f8435dab70bb2117f2c028", + "d4d9c974cbd7460ba1869ae1eff2713e", + "d505227f186c448ca43b319c7190cce8", + "d51af3c024974fd4a55a29e3efc02778", + "d554b1b0592f4a959d308163c8198f15", + "d567c1b52a7648039bc7c745d0f0f6ea", + "d58e16be708641bbb67bef7756c264f3", + "d598ec61d4604cf2a6d169c16cd83f40", + "d5ba86e63ce34c2e8b667c08b8388fa2", + "d5fe8fa550774448bb705c918626a89c", + "d6244b772556425aa0719f72f47f5a9e", + "d693a896d5b6421b9e91da323700763c", + "d6b6beb40f3740f98d6e62cfb1dbafd6", + "d70fb711ca144e4aa88fccbd3e9b953b", + "d71a3a4aa29345dfbc6986a3a2b30404", + "d7754b178b0441ba9e7bdd745ae48b54", + "d78b9fa47c804b45b93c7ded92f496ae", + "d7b53f7a06d24bf88c13f064e8b1daf4", + "d829b2af64ed4e768ca0f5fb61923a32", + "d8954918a2cc41118bbff24bb5d92d5a", + "d8b5b0ec5b674167bb9ab95e4df8e519", + "d8b82661ee5e4468adf543c4b57dc204", + "d8d275c708824b109fa1297ebfcc74be", + "d8f51242328347e98570a58b7564c5ea", + "d933e7727823491c94025bbf886eb424", + "d9bb32ba0eb0499fbb3972c3e449c3b7", + "d9bc43234b8e4894a372f4b6e705d7af", + "da6f7ecda49f43d79897321e45c2c8d5", + "daf42136b3a4496582a99905a5bdb30b", + "db00da4ecad2464f90443946ef0d3f78", + "dbe81be2e99d467fb66b809bd2de103e", + "dc169f1f99ed44989758641543ad378f", + "dc1dc5fda19849049a1bae32f467d3ad", + "dd112e4ff0ec457e9ff99d5f05455899", + "dd8fde5ae60246fbad84ce4b514ff2cd", + "dd9bf10537b94e899cc1bb105ccd8ef5", + "dda659e7ef50458ea767236a1d32806c", + "ddaf7d39883447dcbc656c30b14d747a", + "ddf4ec21987f45afbc1dd99d70992403", + "de25557f3d2e4858a9875202e3c7ec3b", + "de3a5b4af24d473d83dd3b9b07909734", + "df1c877a3fcd4267a8017a555b9a93af", + "df62489796904cc282817d40e0cb9212", + "df743c7130744cd69cee56ed04280da9", + "e094cb8161db4528ae96da91ac44f7a1", + "e0d2565a7dd24393a5e7fd94fc196c1e", + "e103ea826b9d436095d59a1a24a740dc", + "e1c5b300a8954df186146a9ddfeda308", + "e1cc1af54f084296942a4e15e77e57aa", + "e1fd1d448ec643919138d22c7ea20e85", + "e21bbd53671842fdbd3beec7b72a6f91", + "e22ae7e7d2a040c2a413c030444ace85", + "e24a8b055f0d4595a0d25884781cfb18", + "e253e6fc24184af9b003c8144071e625", + "e25d2ad117c64b398a0311ab486d9a20", + "e28fe7fb98f74d9c9f4083e4c27303d5", + "e2a82798cd8946ecad2c7b5786ae8b0f", + "e2d82da4c81b4cc79b797e585b7aa458", + "e2ed260e82b24b48874a36abc00e45ba", + "e32d91d56182476c9b0262da1419ee53", + "e367f513bf4845fbb52981f05825df97", + "e368e3b3e03d43aba6eccfaaadcd3ece", + "e3bdbffe843d49e69259486df51ede4c", + "e400169aac0146d59e553bf27fccaf85", + "e4b4e27a424e457da2be11f531d5eabb", + "e50e543ee159433e846d4c21647374d5", + "e555ab1d60b34fdca5d53941904b12b5", + "e55a7d4e1fbf4deda86a4d7f03ed983a", + "e5894a98a94442498c614a5acc100553", + "e5b6de931ad047b5b2bc2287b098304a", + "e6008b0272484418baedd495e0459b1e", + "e6417983e4aa4999ae5abe538049b2ed", + "e65c4696efbd4942a3757902fdb2e43d", + "e6c0aa3774bf465f95f46b19f84e94b7", + "e6c69f8d7ed34b68bc2d19c3045624e2", + "e737c9e73b0645299ef9a0bf49529d04", + "e7a0c77193c64402b455d022bcdf4d7d", + "e7bd0c41280a447f92f5366aea6d8c3c", + "e7bec9dd72bd4640888237a4450ad186", + "e8126c66f1af425f9ad864849fa5c587", + "e81e0094e371465f900f371e1d25f945", + "e82a18976a5645eb9b67ebb82b6f310a", + "e84b872fbad048bdab8475d5355303ef", + "e8b865d6f0714531ac165263905b3925", + "e8def0fa68a1420aa2640a0f5877b29f", + "e948f42b9d264899913a153a0a7aeb4d", + "e95188a1d0b94ea29f57c697f25430ad", + "e9581662a69341b0851efaf8a288f5a2", + "e963e10f97d147fba938c3703e38db64", + "e99b069db48d4bcc9ed83f8564f7a49d", + "e9cc1d67e6414587a27ead0c65e7489f", + "e9dd863430f941fb801f0e1d2fcc60a8", + "e9f4fede1e90453fb081224b23d25296", + "ea57089330bf47758f7a63c891afb332", + "ea65e75d334f402980f3fac89fa61ac7", + "eae8e103012c4f3d87c8b68513d91d97", + "eaef94feb9bd44e2879fa7ba6bcde0a2", + "eb5ba7daafd245d7a519d47a956f3638", + "ebbf78a457b342b3a832aea1db4864f1", + "ec223ab74b0743e1b9ed1415b380bdd6", + "eca401a941e94d659de543259d0ebcb6", + "ecbb2420a05043f1b7399f3947b7901c", + "ecbca03a11dc4ff299d17f7f348c91bd", + "ecf9bc455a58493a85559b93144a61be", + "ed19072b62434887966eb9c001cde66f", + "ed2b8109166941988f1fa7635ad2b0c1", + "ed4fb721d6654f2ab7c40b6e08a19564", + "edb0c54997b149a0bf658b73146273ca", + "edcb0e86243f40fd9733470afb47025a", + "edcdc2e845b74de98ef076d22cd152f0", + "edfef9cdc4194d0ea9a5d0e41c0a45a1", + "ee010f9ba6aa49baa7f241899e2cc3ed", + "ee4c1ec5c2174bf7ace6f282398b03d6", + "ee5eff7c62244809b65a8f64cd24f11c", + "ee9e3e95f5034a4b9e2ec63e6eb43039", + "eef200c231894d52becfeb6b639f1f64", + "ef160c13522a43939011165a95233758", + "efac5c2201854eaea4ad75b24d9d58cb", + "efbfc2a9c75e4031a8f186a089418a43", + "effdaa6e4474477a8ea1115dbc92662b", + "f0583ba038f84cacb903dc6ce700ebbe", + "f133eafa1de1461c9e89da047ec11e77", + "f184ffda73784e97ad6b8a5ca12498e5", + "f1919e3b6baf4ce7b062e648049c8183", + "f1b21b3602ac4b648657cd19be2482a6", + "f2213805ddcd43f4ade8b437c2d5e573", + "f28c9593549a4a79971895b098cf73f7", + "f36a37e2240147ccbc587f6e29946c71", + "f3889acf84444fd283d4df0131fe444c", + "f39a09b01dd94692bcb7946a4d8ce8e6", + "f39d7b1ba8c34e10b5a8f42ce9dcc4f9", + "f3b22096447749439a9c8f65d43fca91", + "f4558a6776024bf092a84de260adbe2a", + "f4e23a8145bb498aab99b1af95191ed4", + "f4fdd94ea75e497fbdc35a7e0f37c7a8", + "f5268e2eb8b6441db2025c6724536e27", + "f569915d9d2042339a9186ff648aabc3", + "f5a0237a633b4ad080d838a394f5cc3d", + "f5cb804a5c7c4b6b92e6d004189c2de0", + "f60753651b9a4f86aa9fe8d92699a858", + "f628361b4aae47ba88110e3a3feb4e7d", + "f65bb75ce96f4af78ea8cc17bdbd9019", + "f69b2e2c5cff4a15b6af44bb7bc5ff47", + "f69fae9659324d268a41503ba4c980c7", + "f6e1e76a9c734cdeac51d1d1f925c19b", + "f6f5d26e96614822b9b077a64f04519c", + "f7ce26e5d3db4447b08a2b7446117551", + "f7f558ee88494c608153ea892b2201a9", + "f92a293cede94b46b5a70248ec365c21", + "f944dbe874ce4623af37cedec6273738", + "f9d4b0e43a264821a19dcb76831885be", + "fa2f37d15f4142f5bc9617e968a728d0", + "faf6c47b38d14b4bada9de1be87d0128", + "fb0fe88727e648f79556e35fe969d73b", + "fba794056ed84e69b9c98bc5505478a6", + "fbaeaaa71b7b4f33b843f2fba3bf1e74", + "fbafb6e97cf84ed5b349db9a26bb6321", + "fbd3e814715b43d0a5b8cb4aaa0a6362", + "fc57b7efc73340f282ede6d69b37ae92", + "fc81380838434879aff900407de394ff", + "fd1feb52d9a9406fbd3c45a523c77c3b", + "fd26d88196154c62a112c226ae34e499", + "fd3dfc2c766e472eb1aae67f63a6d556", + "fd879b84bfe24afcb84fb70e4249238f", + "fdaee3ef74fc47bbbf324501812793bc", + "fe58a8b2a003471db5eafb2e10e20482", + "fe5b68070a0940f1b84646ae9f83aafe", + "fe9ac00cb55044f893c922a386942583", + "fec0761d1ce24604b6ed8e6fbb9bea26", + "fee44ff09d2a41809952b0041f9935e2", + "ff24099feee54330aa5003a6ac8de70a", + "fff94381421e47d4b7042d6b57b53129", + "fff9e100709c42789d0212a04aa826fc" + ], + "fullReportSha256": "eb129d33bdb2a566c9bc87a8f8ce5c77d0bbda7ae5ace585f94808a3bcc3c012", + "battles": 1110, + "frames": 339649, + "periodicFrames": 62266, + "periodicAdjustmentCount": 53068, + "adjustments": { + "Burn/Health/Loss": { + "count": 16480, + "netAmount": -1310646, + "absoluteAmount": 1310646, + "criticalCount": 0, + "reducedCount": 1248 + }, + "Burn/Shield/Loss": { + "count": 10650, + "netAmount": -429593, + "absoluteAmount": 429593, + "criticalCount": 0, + "reducedCount": 10196 + }, + "Damage/Health/Loss": { + "count": 16879, + "netAmount": -4385059, + "absoluteAmount": 4385059, + "criticalCount": 3852, + "reducedCount": 0 + }, + "Damage/Shield/Loss": { + "count": 7066, + "netAmount": -1087958, + "absoluteAmount": 1087958, + "criticalCount": 1547, + "reducedCount": 0 + }, + "Heal/Health/Gain": { + "count": 6510, + "netAmount": 1954466, + "absoluteAmount": 1954466, + "criticalCount": 913, + "reducedCount": 0 + }, + "Poison/Health/Loss": { + "count": 10503, + "netAmount": -1069514, + "absoluteAmount": 1069514, + "criticalCount": 0, + "reducedCount": 0 + }, + "Regen/Health/Gain": { + "count": 15435, + "netAmount": 1060113, + "absoluteAmount": 1060113, + "criticalCount": 0, + "reducedCount": 0 + }, + "Sandstorm/Health/Loss": { + "count": 3160, + "netAmount": -124305, + "absoluteAmount": 124305, + "criticalCount": 0, + "reducedCount": 0 + }, + "Sandstorm/Shield/Loss": { + "count": 3292, + "netAmount": -160118, + "absoluteAmount": 160118, + "criticalCount": 0, + "reducedCount": 0 + }, + "Shield/Shield/Gain": { + "count": 15648, + "netAmount": 3539306, + "absoluteAmount": 3539306, + "criticalCount": 1909, + "reducedCount": 0 + } + }, + "sources": { + "resolvedActions": 34078, + "unresolvedActions": 7, + "missingStatSourceIdentities": [], + "unresolvedActionSources": [ + "051572e8946f47978929a5c5f1e0a986/0/Player/PlayerModifyAttribute", + "4d52c948ad824f02a2c0199e0a312e13/0/Player/PlayerModifyAttribute", + "89a1556ee12f4700b62517124707be7e/0/Player/PlayerModifyAttribute", + "96a0666936df43d2ba06e51a0c34f3c6/0/Player/PlayerModifyAttribute", + "97c600b9118d4751bf160317cab00959/0/Player/PlayerModifyAttribute", + "fc57b7efc73340f282ede6d69b37ae92/0/Player/PlayerModifyAttribute", + "fee44ff09d2a41809952b0041f9935e2/0/Player/PlayerModifyAttribute" + ], + "multiSourceBattlesByStatus": { + "Burn": 743, + "Poison": 428, + "Regen": 343 + } + }, + "rules": { + "burnShieldBridge": { + "observed": 26073, + "matched": 25510, + "mismatched": 563, + "skipped": 0, + "matchedAmount": 1681443, + "mismatchedAmount": 58796 + }, + "burnDecay": { + "observed": 23463, + "matched": 23463, + "mismatched": 0, + "skipped": 2610, + "matchedAmount": 65066, + "mismatchedAmount": 0 + }, + "poisonTick": { + "observed": 10503, + "matched": 10362, + "mismatched": 141, + "skipped": 0, + "matchedAmount": 1032416, + "mismatchedAmount": 37098 + }, + "regenTick": { + "observed": 11627, + "matched": 11616, + "mismatched": 11, + "skipped": 3808, + "matchedAmount": 991705, + "mismatchedAmount": 9769 + }, + "healthLedger": { + "observed": 62266, + "reconciled": 62047, + "mismatched": 219, + "skipped": 0, + "attemptedGain": 1996094, + "realizedGain": 1270692, + "loss": 3741674 + }, + "shieldLedger": { + "observed": 62266, + "reconciled": 62266, + "mismatched": 0, + "skipped": 0, + "attemptedGain": 1013420, + "realizedGain": 1013420, + "loss": 812321 + }, + "realizedTotals": { + "burnHealthDamage": 1227284, + "burnShieldConsumed": 428748, + "poisonHealthDamage": 1010235, + "regenAttempted": 994167, + "regenRealized": 745285 + } + }, + "terminalImpact": { + "deathEvents": 1110, + "combatantsWithMultipleDeathEvents": 0, + "framesAfterFirstDeathEvent": 0, + "positiveHealthFramesAfterFirstDeathEvent": 0, + "periodicAdjustmentFramesAfterFirstDeathEvent": 0, + "periodicAdjustmentAmountAfterFirstDeathEvent": {}, + "periodicHealthLossOnDeathEventFrames": { + "Burn": 145563, + "Poison": 127577 + }, + "combatEffectivePeriodicHealthLossOnDeathEventFrames": { + "Burn": 62201, + "Poison": 68298 + }, + "periodicHealthOverkillOnDeathEventFrames": { + "Burn": 83362, + "Poison": 59279 + }, + "reversedOrderCombatEffectivePeriodicHealthLossOnDeathEventFrames": { + "Burn": 70438, + "Poison": 66875 + }, + "burnShieldConsumedOnDeathEventFrames": 12773, + "combatEffectiveBurnShieldConsumedOnDeathEventFrames": 118, + "burnShieldConsumedAfterLethalHealthOnDeathEventFrames": 12655, + "reversedOrderCombatEffectiveBurnShieldConsumedOnDeathEventFrames": 12632, + "atomicBurnGroupCombatEffectiveHealthLossOnDeathEventFrames": 62201, + "atomicBurnGroupCombatEffectiveShieldConsumedOnDeathEventFrames": 11928 + }, + "attribution": { + "results": 5821, + "exactResults": 1365, + "constrainedResults": 0, + "proportionalResults": 4456, + "resultsByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 639, + "Burn/Proportional": 2272, + "Poison/Constrained": 0, + "Poison/Exact": 575, + "Poison/Proportional": 1057, + "Regen/Constrained": 0, + "Regen/Exact": 151, + "Regen/Proportional": 1127 + }, + "healthByKind": { + "Burn": 1219514, + "Poison": 1001417, + "Regen": 686099 + }, + "shieldByKind": { + "Burn": 425085, + "Poison": 0, + "Regen": 0 + }, + "healthByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 313026, + "Burn/Proportional": 906488, + "Poison/Constrained": 0, + "Poison/Exact": 508709, + "Poison/Proportional": 492708, + "Regen/Constrained": 0, + "Regen/Exact": 56809, + "Regen/Proportional": 629290 + }, + "shieldByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 130023, + "Burn/Proportional": 295062, + "Poison/Constrained": 0, + "Poison/Exact": 0, + "Poison/Proportional": 0, + "Regen/Constrained": 0, + "Regen/Exact": 0, + "Regen/Proportional": 0 + }, + "allocationDecisions": 80303, + "allocationDecisionsByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 11227, + "Burn/Proportional": 39043, + "Poison/Constrained": 0, + "Poison/Exact": 6869, + "Poison/Proportional": 9683, + "Regen/Constrained": 0, + "Regen/Exact": 1833, + "Regen/Proportional": 11648 + }, + "allocatedHealthByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 345182, + "Burn/Proportional": 874332, + "Poison/Constrained": 0, + "Poison/Exact": 524822, + "Poison/Proportional": 476595, + "Regen/Constrained": 0, + "Regen/Exact": 57378, + "Regen/Proportional": 628721 + }, + "allocatedShieldByKindAndProof": { + "Burn/Constrained": 0, + "Burn/Exact": 142484, + "Burn/Proportional": 282601, + "Poison/Constrained": 0, + "Poison/Exact": 0, + "Poison/Proportional": 0, + "Regen/Constrained": 0, + "Regen/Exact": 0, + "Regen/Proportional": 0 + }, + "unattributedHealthByKind": { + "Burn": 7770, + "Poison": 8818, + "Regen": 59186 + }, + "unattributedShieldByKind": { + "Burn": 3663, + "Poison": 0, + "Regen": 0 + }, + "unattributedHealthByOrigin": { + "ContaminatedEpoch": 16588, + "HealthLedgerUnreconciled": 7464, + "InitialState": 909, + "InitialState, MissingApplySource": 4009, + "MissingApplySource": 38, + "StatusStateAbsent": 102558 + }, + "unattributedShieldByOrigin": { + "ContaminatedEpoch": 3663, + "HealthLedgerUnreconciled": 0, + "InitialState": 0, + "InitialState, MissingApplySource": 0, + "MissingApplySource": 0, + "StatusStateAbsent": 0 + }, + "unattributedHealthByContext": { + "ContaminatedEpoch/MeasuredSource/ApplySameFrame/Later": 155, + "ContaminatedEpoch/MeasuredSource/NoApply/Later": 16433, + "HealthLedgerUnreconciled/Excluded/ApplySameFrame/Later": 2233, + "HealthLedgerUnreconciled/Excluded/NoApply/Later": 5231, + "InitialState, MissingApplySource/MeasuredSource/ApplySameFrame/Later": 212, + "InitialState, MissingApplySource/MeasuredSource/NoApply/Later": 3797, + "InitialState/MeasuredSource/NoApply/Frame0": 2, + "InitialState/MeasuredSource/NoApply/Later": 907, + "MissingApplySource/MeasuredSource/NoApply/Later": 38, + "StatusStateAbsent/Excluded/NoApply/Frame0": 152, + "StatusStateAbsent/Excluded/NoApply/Later": 48176, + "StatusStateAbsent/MeasuredSource/NoApply/Frame0": 67, + "StatusStateAbsent/MeasuredSource/NoApply/Later": 54163 + }, + "unattributedRegenByCandidateSurfaces": { + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:0/prior:0/display:0": 491, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:1/prior:1/display:1": 205, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:2/prior:2/display:2": 57, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:3/prior:3/display:3": 97, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:0/events:5/prior:5/display:5": 172, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:1/events:0/prior:0/display:0": 98, + "HealthLedgerUnreconciled/Excluded/stats:0/attributes:3/events:0/prior:0/display:0": 38, + "HealthLedgerUnreconciled/Excluded/stats:1/attributes:1/events:1/prior:1/display:1": 436, + "HealthLedgerUnreconciled/Excluded/stats:1/attributes:3/events:1/prior:1/display:1": 120, + "HealthLedgerUnreconciled/Excluded/stats:2/attributes:2/events:2/prior:1/display:2": 822, + "HealthLedgerUnreconciled/Excluded/stats:2/attributes:2/events:2/prior:2/display:2": 3400, + "HealthLedgerUnreconciled/Excluded/stats:2/attributes:3/events:2/prior:2/display:2": 143, + "HealthLedgerUnreconciled/Excluded/stats:3/attributes:4/events:3/prior:3/display:3": 1343, + "HealthLedgerUnreconciled/Excluded/stats:4/attributes:5/events:4/prior:4/display:4": 42, + "InitialState, MissingApplySource/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 1747, + "InitialState, MissingApplySource/MeasuredSource/stats:0/attributes:0/events:1/prior:0/display:1": 47, + "InitialState, MissingApplySource/MeasuredSource/stats:0/attributes:0/events:1/prior:1/display:1": 1865, + "InitialState, MissingApplySource/MeasuredSource/stats:2/attributes:3/events:2/prior:0/display:2": 92, + "InitialState, MissingApplySource/MeasuredSource/stats:2/attributes:3/events:2/prior:1/display:2": 68, + "InitialState, MissingApplySource/MeasuredSource/stats:2/attributes:3/events:2/prior:2/display:2": 190, + "InitialState/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 66, + "InitialState/MeasuredSource/stats:0/attributes:1/events:0/prior:0/display:0": 843, + "MissingApplySource/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 38, + "StatusStateAbsent/Excluded/stats:0/attributes:0/events:0/prior:0/display:0": 48328, + "StatusStateAbsent/MeasuredSource/stats:0/attributes:0/events:0/prior:0/display:0": 38942, + "StatusStateAbsent/MeasuredSource/stats:0/attributes:1/events:0/prior:0/display:0": 13793, + "StatusStateAbsent/MeasuredSource/stats:0/attributes:2/events:0/prior:0/display:0": 1088, + "StatusStateAbsent/MeasuredSource/stats:0/attributes:3/events:0/prior:0/display:0": 293, + "StatusStateAbsent/MeasuredSource/stats:0/attributes:4/events:0/prior:0/display:0": 114 + }, + "regenRealizedBySpawnBaseline": { + "Opponent/spawn:positive/combat-transition:no/frame-transition:no/attempt:equals-opening": 54572, + "Opponent/spawn:positive/combat-transition:yes/frame-transition:no/attempt:equals-opening": 285926, + "Opponent/spawn:positive/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 33062, + "Opponent/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 66083, + "Opponent/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 12768, + "Player/spawn:zero/combat-transition:no/frame-transition:no/attempt:opening-zero": 48613, + "Player/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 270948, + "Player/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 27656, + "Player/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:other": 1449 + }, + "regenAttributedBySpawnBaseline": { + "Opponent/spawn:positive/combat-transition:yes/frame-transition:no/attempt:equals-opening": 280274, + "Opponent/spawn:positive/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 31041, + "Opponent/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 65947, + "Opponent/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 12717, + "Player/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 268969, + "Player/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 27147, + "Player/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:other": 4 + }, + "regenAttributedBySourceSurfaces": { + "stats:yes/attribute:no/prior-events:no": 3280, + "stats:yes/attribute:no/prior-events:yes": 289570, + "stats:yes/attribute:yes/prior-events:no": 4259, + "stats:yes/attribute:yes/prior-events:yes": 388990 + }, + "regenAttributedSpawnBaselineSources": {}, + "regenUnknownBySpawnBaseline": { + "InitialState, MissingApplySource/Opponent/spawn:positive/combat-transition:yes/frame-transition:no/attempt:equals-opening": 394, + "InitialState, MissingApplySource/Opponent/spawn:positive/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 263, + "InitialState, MissingApplySource/Player/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 1827, + "InitialState, MissingApplySource/Player/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 80, + "InitialState, MissingApplySource/Player/spawn:zero/combat-transition:yes/frame-transition:yes/attempt:other": 1445, + "InitialState/Opponent/spawn:positive/combat-transition:yes/frame-transition:no/attempt:equals-opening": 854, + "InitialState/Opponent/spawn:positive/combat-transition:yes/frame-transition:yes/attempt:equals-opening": 5, + "InitialState/Player/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 50, + "MissingApplySource/Opponent/spawn:zero/combat-transition:yes/frame-transition:no/attempt:equals-opening": 38, + "StatusStateAbsent/Opponent/spawn:positive/combat-transition:no/frame-transition:no/attempt:equals-opening": 54230 + }, + "regenGapMeasurementMismatchFrames": 25, + "regenGapMeasurementSignedDifference": -706, + "regenGapMeasurementAbsoluteDifference": 706, + "regenStableSpawnBaselineRealized": 54230, + "regenStableSpawnBaselineAttributed": 0, + "regenStableSpawnBaselineUnknown": 54230 + } +} diff --git a/tests/PostCombatImpact.Tests/PeriodicEffectAttributionTests.cs b/tests/PostCombatImpact.Tests/PeriodicEffectAttributionTests.cs new file mode 100644 index 00000000..ae62e385 --- /dev/null +++ b/tests/PostCombatImpact.Tests/PeriodicEffectAttributionTests.cs @@ -0,0 +1,1043 @@ +using BazaarGameShared.Domain.Core; +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Domain.Effect; +using BazaarGameShared.Infra.Messages.CombatSimEvents; +using BazaarGameShared.Infra.Messages.Shared; +using BazaarPlusPlus.Game.PostCombatImpact.Data; +using Xunit; + +namespace PostCombatImpact.Tests; + +public sealed class PeriodicEffectAttributionTests +{ + private const string DamageIcon = ""; + private const string ShieldIcon = ""; + + [Fact] + public void BurnBooksHealthAndShieldWithTwoForOneMitigation() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 100, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Shield, 3, 3), + (EPlayerAttributeType.Burn, 0, 10) + ) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 100, 96), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Shield, 3, 0), + (EPlayerAttributeType.Burn, 10, 9) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Shield, -3), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Health, -4) + ), + }, + ("burner", ECardStats.BurnAdded, 10) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("burner")); + var impact = impacts[new PeriodicImpactKey("burner", CombatImpactPeriodicKind.Burn)]; + + Assert.Equal(4, impact.HealthAmount); + Assert.Equal(3, impact.ShieldAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + + var report = CombatImpactProjector.Project(simulation, Entities("burner")); + var burnGroup = Assert.Single(Assert.Single(report.Sources).Groups); + Assert.Equal(impact, burnGroup.PeriodicImpact); + } + + [Fact] + public void BurnBooksTypedDamageAfterReceivedDamageReduction() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 100, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Shield, 0, 0), + (EPlayerAttributeType.Burn, 0, 10) + ) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 100, 92), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Shield, 0, 0), + (EPlayerAttributeType.Burn, 10, 9), + (EPlayerAttributeType.PercentDamageReduction, 20, 20) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Health, -8) + ), + }, + ("burner", ECardStats.BurnAdded, 10) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("burner"))[ + new PeriodicImpactKey("burner", CombatImpactPeriodicKind.Burn) + ]; + + Assert.Equal(8, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void PoisonAppliesBeforeTheFirstTickAndLaterUsesOpeningOwnership() + { + var simulation = Simulation( + new[] + { + Frame( + "first", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 1000, 997), + (EPlayerAttributeType.HealthMax, 1000, 1000), + (EPlayerAttributeType.Poison, 0, 3) + ), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -3) + ), + Frame( + "second", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 997, 994), + (EPlayerAttributeType.HealthMax, 1000, 1000), + (EPlayerAttributeType.Poison, 3, 6) + ), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -3) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 994, 988), + (EPlayerAttributeType.HealthMax, 1000, 1000), + (EPlayerAttributeType.Poison, 6, 6) + ), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -6) + ), + }, + ("first", ECardStats.PoisonAdded, 3), + ("second", ECardStats.PoisonAdded, 3) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("first", "second")); + var first = impacts[new PeriodicImpactKey("first", CombatImpactPeriodicKind.Poison)]; + var second = impacts[new PeriodicImpactKey("second", CombatImpactPeriodicKind.Poison)]; + + Assert.Equal(9, first.HealthAmount); + Assert.Equal(3, second.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Proportional, first.Proof); + Assert.Equal(CombatImpactPeriodicProof.Proportional, second.Proof); + } + + [Fact] + public void PoisonBooksTypedDamageAfterReceivedDamageReduction() + { + var simulation = Simulation( + new[] + { + Frame( + "poisoner", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 100, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Poison, 0, 10) + ) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 100, 92), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Poison, 10, 10), + (EPlayerAttributeType.PercentDamageReduction, 20, 20) + ), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -8) + ), + }, + ("poisoner", ECardStats.PoisonAdded, 10) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("poisoner"))[ + new PeriodicImpactKey("poisoner", CombatImpactPeriodicKind.Poison) + ]; + + Assert.Equal(8, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void RegenBooksOnlyHealingAcceptedByTheHealthCap() + { + var simulation = Simulation( + new[] + { + Frame( + "regenerator", + EActionCommandType.PlayerRegenApply, + Attributes( + (EPlayerAttributeType.Health, 95, 95), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 0, 10) + ) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 95, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 10) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10) + ), + }, + ("regenerator", ECardStats.RegenAdded, 10) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("regenerator")); + var impact = impacts[new PeriodicImpactKey("regenerator", CombatImpactPeriodicKind.Regen)]; + + Assert.Equal(5, impact.HealthAmount); + Assert.Equal(0, impact.ShieldAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void RegenBooksTypedHealingAfterSameFrameStatusDecay() + { + var simulation = Simulation( + new[] + { + Frame( + "regenerator", + EActionCommandType.PlayerRegenApply, + Attributes( + (EPlayerAttributeType.Health, 50, 50), + (EPlayerAttributeType.HealthMax, 200, 200), + (EPlayerAttributeType.HealthRegen, 0, 100) + ) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 50, 147), + (EPlayerAttributeType.HealthMax, 200, 200), + (EPlayerAttributeType.HealthRegen, 100, 97) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 97) + ), + }, + ("regenerator", ECardStats.RegenAdded, 100) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("regenerator"))[ + new PeriodicImpactKey("regenerator", CombatImpactPeriodicKind.Regen) + ]; + + Assert.Equal(97, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void HealthLedgerLetsDamageCreateRoomForSameFrameRegen() + { + var setup = Frame( + "poisoner", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 100, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Poison, 0, 30), + (EPlayerAttributeType.HealthRegen, 0, 50) + ) + ); + setup.Events.Add(Execution("regenerator", EActionCommandType.PlayerRegenApply)); + var tick = Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 100, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Poison, 30, 30), + (EPlayerAttributeType.HealthRegen, 50, 50) + ), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -30), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 50) + ); + var simulation = Simulation( + new[] { setup, tick }, + ("poisoner", ECardStats.PoisonAdded, 30), + ("regenerator", ECardStats.RegenAdded, 50) + ); + + var impacts = PeriodicEffectAttribution.Project( + simulation, + Entities("poisoner", "regenerator") + ); + + Assert.Equal( + 30, + impacts[new PeriodicImpactKey("poisoner", CombatImpactPeriodicKind.Poison)].HealthAmount + ); + Assert.Equal( + 30, + impacts[ + new PeriodicImpactKey("regenerator", CombatImpactPeriodicKind.Regen) + ].HealthAmount + ); + } + + [Fact] + public void RegenAfterLethalDamageIsNotRealizedWhenCombatantDies() + { + var simulation = Simulation( + new[] + { + Frame( + "regenerator", + EActionCommandType.PlayerRegenApply, + Attributes( + (EPlayerAttributeType.Health, 100, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 0, 10) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 100, -40), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 10) + ), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -150), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10) + ), + }, + ("regenerator", ECardStats.RegenAdded, 10) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("regenerator")); + + Assert.Empty(impacts); + } + + [Fact] + public void LethalBurnIsCappedAtRemainingHealth() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 5, 5), + (EPlayerAttributeType.HealthMax, 5, 5), + (EPlayerAttributeType.Burn, 0, 20) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 5, -15), + (EPlayerAttributeType.HealthMax, 5, 5) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Health, -20) + ), + }, + ("burner", ECardStats.BurnAdded, 20) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("burner"))[ + new PeriodicImpactKey("burner", CombatImpactPeriodicKind.Burn) + ]; + + Assert.Equal(5, impact.HealthAmount); + } + + [Fact] + public void PeriodicDamageAfterAnEarlierLethalAdjustmentIsNotRealized() + { + var simulation = Simulation( + new[] + { + Frame( + "poisoner", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 10, 10), + (EPlayerAttributeType.HealthMax, 10, 10), + (EPlayerAttributeType.Poison, 0, 7) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 10, -7), + (EPlayerAttributeType.HealthMax, 10, 10) + ), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -10), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -7) + ), + }, + ("poisoner", ECardStats.PoisonAdded, 7) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("poisoner")); + + Assert.Empty(impacts); + } + + [Fact] + public void DeathEventRemainsTerminalAfterLaterNumericHealing() + { + var simulation = Simulation( + new[] + { + Frame( + "poisoner", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 10, 10), + (EPlayerAttributeType.HealthMax, 10, 10), + (EPlayerAttributeType.Poison, 0, 5) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 10, 5), + (EPlayerAttributeType.HealthMax, 10, 10) + ), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -20), + Adjustment(EDamageType.Heal, EPlayerHealthChangeType.Health, 20), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -5) + ), + }, + ("poisoner", ECardStats.PoisonAdded, 5) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("poisoner")); + + Assert.Empty(impacts); + } + + [Fact] + public void PeriodicDamageBeforeALaterLethalAdjustmentStillCounts() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 15, 15), + (EPlayerAttributeType.HealthMax, 15, 15), + (EPlayerAttributeType.Burn, 0, 5) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 15, -10), + (EPlayerAttributeType.HealthMax, 15, 15) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Health, -5), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -20) + ), + }, + ("burner", ECardStats.BurnAdded, 5) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("burner"))[ + new PeriodicImpactKey("burner", CombatImpactPeriodicKind.Burn) + ]; + + Assert.Equal(5, impact.HealthAmount); + } + + [Fact] + public void BurnShieldConsumptionAfterLethalHealthDamageIsNotRealized() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 10, 10), + (EPlayerAttributeType.HealthMax, 10, 10), + (EPlayerAttributeType.Shield, 5, 5), + (EPlayerAttributeType.Burn, 0, 20) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 10, 0), + (EPlayerAttributeType.HealthMax, 10, 10), + (EPlayerAttributeType.Shield, 5, 0) + ), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -10), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Shield, -5) + ), + }, + ("burner", ECardStats.BurnAdded, 20) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("burner")); + + Assert.Empty(impacts); + } + + [Fact] + public void BurnShieldConsumptionBeforeLethalHealthDamageStillCounts() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 10, 10), + (EPlayerAttributeType.HealthMax, 10, 10), + (EPlayerAttributeType.Shield, 5, 5), + (EPlayerAttributeType.Burn, 0, 20) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 10, 0), + (EPlayerAttributeType.HealthMax, 10, 10), + (EPlayerAttributeType.Shield, 5, 0) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Shield, -5), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -10) + ), + }, + ("burner", ECardStats.BurnAdded, 20) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("burner"))[ + new PeriodicImpactKey("burner", CombatImpactPeriodicKind.Burn) + ]; + + Assert.Equal(5, impact.ShieldAmount); + } + + [Fact] + public void BurnHealthAndShieldAdjustmentsShareOneAtomicTick() + { + var simulation = Simulation( + new[] + { + Frame( + "burner", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 5, 5), + (EPlayerAttributeType.HealthMax, 5, 5), + (EPlayerAttributeType.Shield, 5, 5), + (EPlayerAttributeType.Burn, 0, 20) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 5, -15), + (EPlayerAttributeType.HealthMax, 5, 5), + (EPlayerAttributeType.Shield, 5, 0) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Health, -20), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Shield, -5) + ), + }, + ("burner", ECardStats.BurnAdded, 20) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("burner"))[ + new PeriodicImpactKey("burner", CombatImpactPeriodicKind.Burn) + ]; + + Assert.Equal(5, impact.HealthAmount); + Assert.Equal(5, impact.ShieldAmount); + } + + [Fact] + public void RegenBeforeLethalDamageStillCountsOnDeathFrame() + { + var simulation = Simulation( + new[] + { + Frame( + "regenerator", + EActionCommandType.PlayerRegenApply, + Attributes( + (EPlayerAttributeType.Health, 90, 90), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 0, 10) + ) + ), + DeathFrame( + Attributes( + (EPlayerAttributeType.Health, 90, -50), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 10) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -150) + ), + }, + ("regenerator", ECardStats.RegenAdded, 10) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("regenerator"))[ + new PeriodicImpactKey("regenerator", CombatImpactPeriodicKind.Regen) + ]; + + Assert.Equal(10, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void RegenCanRescueFromNonPositiveHealthWithoutADeathEvent() + { + var simulation = Simulation( + new[] + { + Frame( + "regenerator", + EActionCommandType.PlayerRegenApply, + Attributes( + (EPlayerAttributeType.Health, 10, 10), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 0, 20) + ) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 10, 10), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 20, 20) + ), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -20), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 20) + ), + }, + ("regenerator", ECardStats.RegenAdded, 20) + ); + + var impact = PeriodicEffectAttribution.Project(simulation, Entities("regenerator"))[ + new PeriodicImpactKey("regenerator", CombatImpactPeriodicKind.Regen) + ]; + + Assert.Equal(20, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void InitialStatusWithoutAnyObservedCandidateIsNotInventedAsCardImpact() + { + var simulation = Simulation( + new[] + { + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 90, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 10) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10) + ), + } + ); + + var impacts = PeriodicEffectAttribution.Project( + simulation, + EntitiesOwnedBy(ECombatantId.Opponent, "possible-source") + ); + + Assert.Empty(impacts); + } + + [Fact] + public void StatusAbsentRegenDoesNotUseUnobservedStaticAttributeCandidate() + { + var simulation = Simulation( + new[] + { + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 90, 100), + (EPlayerAttributeType.HealthMax, 100, 100) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10) + ), + } + ); + var entities = EntitiesOwnedBy(ECombatantId.Opponent, "regenerator") + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + entities["regenerator"] = entities["regenerator"] with + { + Attributes = new Dictionary + { + [ECardAttributeType.RegenApplyAmount] = 10, + }, + }; + + var diagnostics = new PeriodicEffectAttributionDiagnostics(); + var impacts = PeriodicEffectAttribution.Project(simulation, entities, diagnostics); + + Assert.Empty(impacts); + var gap = Assert.Single(diagnostics.Gaps); + Assert.Equal(10, gap.HealthAmount); + Assert.Equal(PeriodicUnknownOrigin.StatusStateAbsent, gap.Origin); + } + + [Fact] + public void InitialRegenUsesWholeCombatCandidateOnlyAsApproximate() + { + var simulation = Simulation( + new[] + { + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 90, 100), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 10) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10) + ), + }, + ("possible-source", ECardStats.RegenAdded, 10) + ); + + var impacts = PeriodicEffectAttribution.Project( + simulation, + EntitiesOwnedBy(ECombatantId.Opponent, "possible-source") + ); + var impact = impacts[ + new PeriodicImpactKey("possible-source", CombatImpactPeriodicKind.Regen) + ]; + + Assert.Equal(10, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Proportional, impact.Proof); + } + + [Fact] + public void InitialRegenFallsBackToLaterObservedRegenSourcesAsApproximate() + { + var simulation = Simulation( + new[] + { + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 50, 60), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 10) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 10) + ), + Frame( + "regenerator", + EActionCommandType.PlayerRegenApply, + Attributes( + (EPlayerAttributeType.Health, 60, 40), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 10, 20) + ), + Adjustment(EDamageType.Damage, EPlayerHealthChangeType.Health, -20) + ), + Frame( + null, + EActionCommandType.None, + Attributes( + (EPlayerAttributeType.Health, 40, 60), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.HealthRegen, 20, 20) + ), + Adjustment(EDamageType.Regen, EPlayerHealthChangeType.Health, 20) + ), + }, + ("regenerator", ECardStats.RegenAdded, 10) + ); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities("regenerator")); + var impact = impacts[new PeriodicImpactKey("regenerator", CombatImpactPeriodicKind.Regen)]; + + Assert.Equal(30, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Proportional, impact.Proof); + } + + [Fact] + public void StatusResetClosesTheOldOwnershipEpoch() + { + var simulation = Simulation( + new[] + { + Frame( + "old-source", + EActionCommandType.PlayerBurnApply, + Attributes((EPlayerAttributeType.Burn, 0, 10)) + ), + Frame( + null, + EActionCommandType.None, + Attributes((EPlayerAttributeType.Burn, 10, 0)) + ), + Frame( + "new-source", + EActionCommandType.PlayerBurnApply, + Attributes( + (EPlayerAttributeType.Health, 100, 94), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Shield, 0, 0), + (EPlayerAttributeType.Burn, 0, 6) + ), + Adjustment(EDamageType.Burn, EPlayerHealthChangeType.Health, -6) + ), + }, + ("old-source", ECardStats.BurnAdded, 10), + ("new-source", ECardStats.BurnAdded, 6) + ); + + var impacts = PeriodicEffectAttribution.Project( + simulation, + Entities("old-source", "new-source") + ); + + Assert.DoesNotContain( + new PeriodicImpactKey("old-source", CombatImpactPeriodicKind.Burn), + impacts + ); + var impact = impacts[new PeriodicImpactKey("new-source", CombatImpactPeriodicKind.Burn)]; + Assert.Equal(6, impact.HealthAmount); + Assert.Equal(CombatImpactPeriodicProof.Exact, impact.Proof); + } + + [Fact] + public void UnresolvedApplySourceRemainsUnknown() + { + var simulation = Simulation( + new[] + { + Frame( + "missing-source", + EActionCommandType.PlayerPoisonApply, + Attributes( + (EPlayerAttributeType.Health, 100, 90), + (EPlayerAttributeType.HealthMax, 100, 100), + (EPlayerAttributeType.Poison, 0, 10) + ), + Adjustment(EDamageType.Poison, EPlayerHealthChangeType.Health, -10) + ), + } + ); + var diagnostics = new PeriodicEffectAttributionDiagnostics(); + + var impacts = PeriodicEffectAttribution.Project(simulation, Entities(), diagnostics); + + Assert.Empty(impacts); + var gap = Assert.Single(diagnostics.Gaps); + Assert.Equal(10, gap.HealthAmount); + Assert.Equal(PeriodicUnknownOrigin.MissingApplySource, gap.Origin); + } + + [Fact] + public void FormatterKeepsProofInternalAndUsesNativeImpactIcons() + { + var group = new CombatImpactGroup( + CombatImpactKind.Burn, + CombatImpactAggregator.NativeKey(CombatImpactKind.Burn), + 1, + 10, + CombatImpactValueUnit.Amount, + CombatImpactCoverage.Exact, + new CombatImpactAuthoritativeMetric( + CombatImpactKind.Burn, + CombatImpactAggregator.NativeKey(CombatImpactKind.Burn), + 10, + CombatImpactValueUnit.Amount, + CombatImpactAuthoritativeBasis.TotalAmount + ), + 0, + [] + ) + { + PeriodicImpact = new CombatImpactPeriodicImpact( + 7, + 2, + CombatImpactPeriodicProof.Proportional, + PeriodicEffectAttribution.ModelVersion + ), + }; + + Assert.Equal("×1 · 10 total", CombatImpactMetricFormatter.Group(group, chinese: false)); + Assert.Equal("×1 · 总计 10", CombatImpactMetricFormatter.Group(group, chinese: true)); + Assert.Equal( + $"7 {DamageIcon} · 2 {ShieldIcon}", + CombatImpactMetricFormatter.PeriodicImpact( + group, + chinese: false, + damageMarker: DamageIcon, + shieldMarker: ShieldIcon + ) + ); + Assert.Equal( + $"7 {DamageIcon} · 2 {ShieldIcon}", + CombatImpactMetricFormatter.PeriodicImpact( + group, + chinese: true, + damageMarker: DamageIcon, + shieldMarker: ShieldIcon + ) + ); + } + + private static CombatSim Simulation( + IReadOnlyList frames, + params (string SourceId, ECardStats Stat, int Value)[] stats + ) + { + var simulation = new CombatSim { Frames = frames.ToList() }; + foreach (var (sourceId, stat, value) in stats) + { + if (!simulation.CardStats.TryGetValue(sourceId, out var sourceStats)) + { + sourceStats = new Dictionary(); + simulation.CardStats[sourceId] = sourceStats; + } + sourceStats[stat] = value; + } + return simulation; + } + + private static CombatSimFrame Frame( + string? sourceId, + EActionCommandType action, + IReadOnlyDictionary attributes, + params CombatSimPlayerHealthAdjustment[] adjustments + ) + { + var frame = new CombatSimFrame + { + OpponentUpdates = new CombatSimPlayerUpdate + { + Attributes = new Dictionary( + attributes + ), + HealthAdjustments = adjustments.ToList(), + }, + }; + if (sourceId != null) + frame.Events.Add(Execution(sourceId, action)); + return frame; + } + + private static CombatSimFrame DeathFrame( + IReadOnlyDictionary attributes, + params CombatSimPlayerHealthAdjustment[] adjustments + ) + { + var frame = Frame(null, EActionCommandType.None, attributes, adjustments); + frame.Events.Add(new CombatSimEventCombatantDied { CombatantId = ECombatantId.Opponent }); + return frame; + } + + private static CombatSimEventEffectExecuted Execution( + string sourceId, + EActionCommandType action + ) => + new() + { + ActionType = action, + Source = InstanceId.TryParse(sourceId), + Target = new EffectTargetPlayer { Target = ECombatantId.Opponent }, + }; + + private static Dictionary Attributes( + params (EPlayerAttributeType Type, int Previous, int Current)[] values + ) => + values.ToDictionary( + value => value.Type, + value => new CombatSimPlayerAttributeUpdate + { + AttributeType = value.Type, + PreviousValue = value.Previous, + CurrentValue = value.Current, + } + ); + + private static CombatSimPlayerHealthAdjustment Adjustment( + EDamageType damageType, + EPlayerHealthChangeType pool, + int amount + ) => + new() + { + DamageType = damageType, + AttributeChanged = pool, + Amount = amount, + }; + + private static IReadOnlyDictionary Entities( + params string[] sourceIds + ) => EntitiesOwnedBy(ECombatantId.Player, sourceIds); + + private static IReadOnlyDictionary EntitiesOwnedBy( + ECombatantId sourceCombatant, + params string[] sourceIds + ) + { + var entities = sourceIds.ToDictionary( + sourceId => sourceId, + sourceId => new CombatImpactEntity( + sourceId, + sourceId, + "Item", + null, + 0, + CombatantId: sourceCombatant + ), + StringComparer.Ordinal + ); + var opponentId = CombatImpactProjector.PlayerId(ECombatantId.Opponent); + entities[opponentId] = new CombatImpactEntity( + opponentId, + "Opponent", + "Hero", + null, + entities.Count, + CombatantId: ECombatantId.Opponent + ); + return entities; + } +} diff --git a/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj b/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj index dc029ee9..d4e7bd63 100644 --- a/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj +++ b/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj @@ -36,6 +36,10 @@ Include="../../src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs" Link="CombatImpactProjector.cs" /> +