Repository navigation
Fix skill-level scaling: unscaled effect kinds + levelScale/levelConst for derived values - #148
Merged
Merged
Conversation
A live Shooting Range comparison (2026-08-25) reported nayuta at 1/1/1 simulating
~158M against ~77M measured. Two silent scaler failures, both confirmed by the new
census tool and reproduced in-sim:
1. SILENT — an effect kind with no case in scaleEffect returned its magnitude
untouched AND emitted no warning. Adds weaponSwap (damagePct/chargeMultPct),
fillGauge.pct, shield.maxHpPct, stackedNuke.atkPct/hpPct, storedHit.atkPct.
weaponSwap.damagePct is the worst of these: a burst weapon mode's per-shot
multiplier is often the carrier's largest damage term, and 11 units carry one.
damagePct is deliberately NOT scaled on a sameWeapon swap — there the gun is
not replaced, so the value is the base weapon's own normalAttackMultiplier
(a weapon stat, level-invariant). No unit ships that combination today.
2. WARNED — a DERIVED authored value (a fold of two kit lines, a time-averaged
stack ramp, a stack-cap product) matches no level-table entry, so it stayed at
max level while emitting "no level table match for N". New optional
`levelScale` names the table anchor(s) the number was derived from; the value
scales as authored x (sum anchors@L)/(sum anchors@10) — exact for a fold,
proportional otherwise. validate-structural rejects an anchor that is not a
real max-level entry, so a typo is a build error, not a silent max-level value.
nayuta annotated from the derivations documented verbatim in her own override note.
Measured in-sim (audit-skill-scaling.ts --sim, liter/crown/nayuta/noise/red-hood):
skill1@1 skill2@1 burst@1 1/1/1
before -8.7% 0.0% -7.3% -15.3%
after -15.9% -9.6% -24.7% -44.3%
skill2 previously moved her damage by exactly 0.0% — all four of its values were
derived. Board blast radius is ZERO: every graded comp runs at 10/10/10, where the
scaler early-returns, so the regression snapshot is unchanged and verify.sh is
green without --update.
Tooling: scripts/audit-skill-scaling.ts is the committed census (SILENT/WARNED per
unit, --sim to size a unit's per-slot sensitivity). Remaining roster-wide backlog
is reported there and left for a batched owner decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, findings doc
Follow-ups on the scaler fix:
- scale.ts no longer scales weaponSwap.chargeMultPct. All 8 carriers author a round
kit constant ("Full Charge Damage: 250% of damage" — 250/300/1750) and not one
resolves to a level-table entry, so scaling it only emitted warnings nobody could
act on. weaponSwap.damagePct (the real fix) is unaffected.
- audit-skill-scaling.ts now RUNS scaleBlocks and observes which fields changed,
instead of hand-mirroring scaleEffect's switch. The mirror had already drifted
within this session — it still reported all 37 SILENT values as broken after they
were fixed. Also anchors the warning match on the full "match for N —" phrase; a
bare digit-substring test classified a maxAmmo of 1 as WARNED.
- Census after both fixes: 37 -> 3 SILENT, 48 units with derived values remaining.
- docs/handoffs/2026-08-25-skill-level-scaling.md: root cause, what landed, and the
per-unit backlog sized by measured impact (--sim) rather than warning count.
drake is the worst — 6 derived values covering essentially her whole kit, so her
1/1/1 damage reads +0.7% vs 10/10/10 (levels effectively inert). QUEUE.md item
added for the batched owner decision.
verify.sh green; board artifacts rebuilt (they hash inputs and nayuta.json changed);
regression snapshot unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reasure gap
Works the levelScale backlog per unit, each anchor taken from that override's OWN
documented derivation rather than a numeric search. Backlog 124 -> 97 values,
47 -> 42 units. Measured level sensitivity at 1/1/1 (was near-flat for several):
mast-romantic-maid -59.0% snow-white-heavy-arms -53.0% eve -37.3%
guillotine-winter-slayer -34.3% sakura-bloom-in-summer -30.6% ein -29.9%
mihara-bonding-chain -23.4% little-mermaid -5.4% -> -22.8% neon-vision-eye -48.7%
New `levelConst`: fields VERIFIED level-invariant, so they stop warning. Three
shapes — a structural constant (eve's Mk2 "doubles S1" as sequentialMultPct +100),
a sentinel (prika's burstCdr -9999 lockout), a non-skill quantity (red-hood's
burstCdr 40 = her own burst cooldown). Distinct from having no annotation, which
still warns; levelConst is a claim someone checked.
scripts/apply-level-scale.ts is the declarative, idempotent applier (--check to
dry-run); every row carries the note quote it rests on. That matters twice over:
the audit's "<- 60 x 12" hint is a brute-force table search and it finds
coincidences. eve's 720% is really 240 x 3 (her note), not 60 x 12; and
snow-white-heavy-arms's 1055.9 is 105.59 x 10 exactly, not the hinted 42.24 x 25
(= 1056.0, off by 0.1).
TWO FINDINGS, both recorded in the handoff doc:
(a) 19 of the 47 backlog units are TREASURE units and are a DIFFERENT bug, blocked
on data. skill-levels.json holds the BASE kit arrays; those overrides model the
treasure kit. drake: table has 11.85/1254/98.55, override authors
20.09/3009.6/201.6 — boosts are non-uniform (x1.70, x2.40, and maxAmmoPct 72.18
not boosted), so no proportional anchor recovers them, and x2.40 is far too
large to be extrapolated skill levels. Blablalink roledata carries no
favorite-item fields at all. Annotating these off a base-kit anchor would be a
guess about favorite-item scaling — deliberately NOT done.
(b) An anchor can live in a DIFFERENT slot's table: ein's skill1 363.24 is 4 x 90.81
but 90.81 is in her SKILL2 table. Not expressible, and would scale off the wrong
slot's level anyway. Left WARNING. The structural validator caught it.
verify.sh green; board artifacts rebuilt; regression snapshot unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…investigation Second annotation pass, driven from PRIMARY kit text (data/characters.json skills) rather than override notes where the notes were silent. Backlog 97 -> 83 values, 42 -> 30 units; 21 actionable units -> 11 residual. Landed this pass — every row a kit-text or note quote: arcana-fortune-mate 39 = 13 x 3 stacks; 7.47 = 2.49 x 3; 30 -> levelConst asuka-wille 198.6 = 6.62 x 30 Anti A.T. Field stacks cinderella 13659.2 = 1365.92 x 10; 346.8 = 28.9 x 12 Beautiful stacks mast 226 = 4.52 x 50 Sea Breeze stacks maiden-ice-rose 137.28 = 1372.8 x 10% (the kit's Max-HP component) soline-frost-ticket 20 = 10 x 2 tickets (NOT the hinted 10+10 fold) emilia 12.06 = 2.01 x 6 | liberalio 202.5 = 40.5 x 5 | mana 18 <- 0.18s ada 61.3 + rapi-red-hood 20 -> levelConst Two more hint-vs-source catches: soline-frost-ticket's 20 is 10 x 2 tickets per the kit, not the hinted 10+10; and arcana-fortune-mate's 30 = 10 x 3 is levelConst, not levelScale, because its 10 anchor is a CONSTANT array in her skill1 table. The 13 residual non-treasure values are NOT simple derivations and are left warning: six are CROSS-SLOT (anchor lives in another slot's table — ein 363.24 = 4 x 90.81 with 90.81 in skill2; eve, neon-vision-eye x2, emma-tactical-upgrade, red-hood), which may mean those blocks are filed under the wrong slot; seven are datamined or kit-literal values that appear in no level array at all. QUEUE.md: replaced the old batched-decision item with two scoped ones — the treasure investigation (with the drake evidence and the two concrete questions: is there another source, or is there a RULE an owner can state) and the 13 residuals. verify.sh green; board artifacts rebuilt; regression snapshot unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The applier wrote overrides with JSON.stringify(ov, null, 2), which expands every compact one-line object in the file. Prettier's default objectWrap:"preserve" then KEEPS them expanded, so a 1-line annotation landed as ~100 lines of pure formatting churn on protected, concurrently-edited files — mana/mast/sakura-bloom-in-summer were 124/96/117 changed lines for a 1-3 line change each. Rewritten to insert the annotation at the target effect's own byte span, leaving the rest of the file untouched. `prettier --object-wrap collapse` was evaluated and rejected: it is a no-op on mana/mast/sakura but rewrites emilia and nayuta, which are legitimately multi-line in main. Net across all 23 annotated overrides: 442 insertions / 118 deletions -> 59 / 2 (the 2 are objects that genuinely crossed the 80-col wrap once annotated). Verified every touched file is deep-equal to its main version after stripping levelScale/levelConst — 23 files checked, 0 semantic diffs. verify.sh green; census unchanged at 83 warnings; snapshot unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r, applier Cross-family review returned FIX-BEFORE-MERGE with 1 BLOCKER / 3 FIX / 3 NOTE. All seven confirmed and fixed. BLOCKER — perResource.mult was still SILENT, and the census could not see it. A perResource buff/DoT IGNORES its static value/atkPct at runtime and recomputes the magnitude as resources[name] x mult, so mult is the real level-scaled number. Eight carriers' primary magnitudes stayed pinned at max level with no warning: mihara-bonding-chain 25.08 (her Ensnaring DoT driver), mana 70.4, phantom 25.75/12.86, marciana-marine-study 32.73, e-h 7.5, guillotine 0.96, soda-twinkling-bunny 1.32 — each a max-level entry of a VARYING array. The census was structurally blind to it (nested under perResource; `mult` also sat in TIME_FIELDS), so its own completeness summary was wrong. Consequence I had published: this branch's level-sensitivity figure for mihara-bonding-chain was materially understated — -23.4% -> -35.2% at 1/1/1. Doc corrected. FIX — findArr took the FIRST max-level match, constant or not. A slot's table holds constant arrays (a "for 10 sec" duration) beside real magnitudes sharing the same max; when the constant came first the magnitude silently stayed at max. Now prefers a varying array. Reviewer confirmed the live cases against kit text: crust's burst "Sustained Damage ▲10%" and prika's burst "Charge Damage ▲25%". Census SILENT 3 -> 0. FIX — apply-level-scale.ts claimed text insertion but still re-serialized. The landed overrides had been normalized out-of-band, so the committed tool did not do what its header and prior commit message claimed; anyone adding a row would have reproduced the churn. Byte-span insertion is now actually implemented (verified idempotent: --check reports 0 applied / 41 already present). FIX — levelScale:null crashed the validator (typeof null === 'object' fell through to Object.entries(null)), taking the whole gate down with a stack trace. Guarded. NOTE x3 — the type union under-declared levelScale/levelConst (now on every kind scaleEffect reads: burstCdr, fillGauge, shield, storedHit); the validator did not check that an annotated field is on a scaling path, nor that levelConst names a real numeric field (both now error, with dotted-path support for perResource.mult); the chargeMultPct comment's counts were wrong (~14 instances / ~10 units incl. one flatDamage carrier — substance held, reviewer re-verified none resolves to a table). Tests: +4 scaler (findArr varying-preference, perResource.mult on buff/dot, levelConst on a dotted path), +6 validator (null guard, anchor resolution, non-scalable field, levelConst typo). 4833 pass. verify.sh green; snapshot unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…an scanner Cross-family re-review returned CLEAN (kimi-code/k3, result-r2.json). Its 1 FOLLOW-UP and 2 NOTEs were fixed rather than filed, since all three were defects in code or claims this branch introduced. FOLLOW-UP — SCALABLE_FIELDS was a hand-maintained MIRROR of scaleEffect's switch, and its comment overclaimed: it said skill-level-scale.test.ts "pins the pairing" when nothing in the suite compared the mirror to the switch. That is the exact staleness class that bit the census in round 1 (its private SCALED map went stale within one session and reported fixed values as still broken). scale.ts now OWNS and exports the map; validate-structural.ts imports it; and a new test probes the REAL scaler against the manifest in both directions — every listed kind/field must actually scale, and a non-listed field must stay untouched. NOTE — objectSpans mis-keyed an object FOLLOWING a scalar in a mixed array (it counted container opens, not elements). Unreachable in today's corpus but a mis-keyed span inserts an annotation into the WRONG effect. Fixed by counting element separators, and pinned by a test that round-trips EVERY span in all 189 override files (slice -> parse -> deep-equal, 192 assertions) plus mixed-array and string-escape cases. Second guard added: two rows inserting a duplicate levelScale/levelConst key into one effect now error, because JSON.parse silently keeps the last and the post-write parse cannot see it. Self-inflicted, caught while fixing the above: exporting objectSpans meant importing the module executed its top-level apply loop — under vitest's argv that is WITHOUT --check, so it would have written to every annotated override. CLI is now behind an import.meta.url === argv[1] guard; verified the override diff is unchanged across a test run. NOTE — doc/QUEUE residual counts said 83; the live census says 84. Reconciled from the instrument's own output. verify.sh green (5040 tests, +207). Snapshot unchanged; census 0 SILENT + 84 WARNED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # src/skills/overrides/mihara-bonding-chain.json
PR #147 reworked her while this branch was open. Both merge conflicts resolved to upstream because its rework replaced the exact structures this branch annotated — and the two changes CONVERGE rather than collide: - the 500.6 (10 x 50.06) dump is now ten paced blocks of a literal atkPct 50.06, a direct max-level entry of her skill1 table, so it scales natively; - the static burst DoT atkPct 1001 (20 x 50.05) is now perResource {mirror, 50.05}, and the permanent Ensnaring DoT is perResource {ensnaring, 25.08} — both covered by the perResource.mult case added for the round-1 review BLOCKER. Her values therefore scale after the merge with NO annotation; both apply-table rows were removed (they would hard-error on "no matching effect" — the staleness guard working). Sensitivity restated -35.2% -> -33.9% at 1/1/1, the -35.2% having been measured against the pre-merge model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DECISIONS (settled WHY): the three ways a value stayed pinned at max level (no case in scaleEffect, findArr taking a constant array over the varying one, and a DERIVED authored value matching no entry), the levelScale/levelConst primitives, the zero-blast-radius argument, the guards, and the two gaps recorded but deliberately not enacted (19 treasure units blocked on data; cross-slot anchors). STATE (current-state registry): levelScale/levelConst added to the opt-in kit-primitive inventory with their carrier lists. Patch notes drafted for owner approval, NOT committed — publishing is owner-gated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scripts/doc-drift.ts --update. Adding two effect-level primitives left engine-modeling-gaps.md's generated census stale, which verify.sh flags. My previous commit chained past that failure and pushed a red tree; this restores it green (108 primitives across 187 overrides). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
A community report compared the Roster Generator against a live Shooting Range fight:
nayutaatskill levels 1/1/1 simulated ~158M against ~77M measured (sim/real ≈ 2.05), and lowering only her
Skill 2 level changed her simulated damage by exactly 0.0%.
src/skills/scale.tsscales an authored override value by matching it against index 9 (max level)of that unit's blablalink per-level arrays and substituting index L−1. Three independent ways a
value stayed pinned at max level:
scaleEffect's switch — the magnitude was returned untouched and emitted nowarning.
weaponSwap.damagePctwas the worst (a burst weapon mode's per-shot multiplier isoften the carrier's largest damage term; 11 units carry one) — exactly the reporter's
unexplained "burst level changed damage with no Modeling Note". Also
shield.maxHpPct(21values / 16 units),
fillGauge.pct(can shift full-burst counts),stackedNuke,storedHit,and
perResource.mult— the LIVE magnitude of a perResource buff/DoT, covering 8 more carriers.findArrtook the first index-9 match, constant or varying. Slot tables hold constantarrays (a "for 10 sec" duration is
[10,10,…]) beside real magnitudes sharing the same max.Now prefers a VARYING array (
crust,prika).nayuta530.46 = 150 +380.46), a time-averaged stack ramp (14.4 from 15.2), a stack-cap product (42 = 1.4 × 30). Fixed
per-value by the new
levelScaleannotation, withlevelConstfor verified-invariantfields.
Measured effect (1/1/1 vs 10/10/10)
nayutamast-romantic-maidsnow-white-heavy-armsneon-vision-eyelittle-mermaidCensus: 0 SILENT + 84 WARNED (was 3 + 124), 47 → 30 units.
Blast radius: ZERO on the graded board
No magnitude changed — only how each behaves below level 10. Every graded comp runs 10/10/10, where
scaleBlocksearly-returns, so the regression snapshot is unchanged and was never regenerated.verify.shgreen (5041 tests).Guards
scale.tsand imported by thevalidator — never mirrored. A private copy in the census went stale within one session and
reported fixed values as still broken; a test now probes the real scaler against the map in both
directions.
scripts/audit-skill-scaling.ts(census) runs the real scaler and observes what changedrather than mirroring its switch.
--sim <slug>sizes per-slot sensitivity.scripts/apply-level-scale.tsis the declarative, idempotent applier; every row carries thenote/kit quote it rests on. The census's
← 60 × 12hint is a brute-force table search thatfinds coincidences —
eve's 720% is really 240 × 3 per her note, andsnow-white-heavy-arms's 1055.9 is 105.59 × 10 exactly (the hinted 42.24 × 25 = 1056.0).Review
Cross-family (
kimi-code/k3), two rounds — packets and verdicts inscratchpad/gates/2026-08-25-skill-level-scaling/.perResource.mult, which also meant this branch'spublished
mihara-bonding-chainfigure was understated), 3 FIX, 3 NOTE. All confirmed and fixed.Merge with
origin/main(PR #147)Both conflicts resolved to upstream, and the changes converge:
mihara-bonding-chain's500.6dump is now ten paced blocks of a literal50.06(native table match) and her burst DoT isperResource {mirror, 50.05}— covered by theperResource.multfix. Her two apply-table rows wereremoved; she scales with no annotation.
Deliberately NOT enacted (queued)
data/skill-levels.jsonholds the BASEkit arrays while those overrides model the treasure kit.
drake: table11.85/1254/98.55vsoverride
20.09/3009.6/201.6; boosts are non-uniform (×1.70, ×2.40, one line unboosted), andblablalink roledata has no favorite-item fields. Annotating off a base-kit anchor would be a
guess about favorite-item scaling. Needs a source or an owner ruling.
einskill1363.24= 4 × 90.81 with90.81in her skill2 table(also
eve,neon-vision-eye,emma-tactical-upgrade,red-hood). May indicate those blocksare filed under the wrong slot.
⚠ Patch notes NOT included — needs your approval
This changes player-visible numbers, so it wants a Patch Notes entry, but publishing is owner-gated.
Draft is in the PR discussion; say the word and I'll add it to this branch before merge.
🤖 Generated with Claude Code