Skip to content

Fix skill-level scaling: unscaled effect kinds + levelScale/levelConst for derived values - #148

Merged
Infernal-Crack-LED merged 11 commits into
mainfrom
skill-level-scaling
Aug 25, 2026
Merged

Infernal-Crack-LED merged 11 commits into
mainfrom
skill-level-scaling

Conversation

@Infernal-Crack-LED

Copy link
Copy Markdown
Owner

What this fixes

A community report compared the Roster Generator against a live Shooting Range fight: nayuta at
skill 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.ts scales 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:

  1. No case in scaleEffect's switch — the magnitude was returned untouched and emitted no
    warning
    . weaponSwap.damagePct was the worst (a burst weapon mode's per-shot multiplier is
    often 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 (21
    values / 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.
  2. findArr took the first index-9 match, constant or varying. Slot tables hold constant
    arrays (a "for 10 sec" duration is [10,10,…]) beside real magnitudes sharing the same max.
    Now prefers a VARYING array (crust, prika).
  3. The authored value is DERIVED, so it matches no entry — a fold (nayuta 530.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 levelScale annotation, with levelConst for verified-invariant
    fields.

Measured effect (1/1/1 vs 10/10/10)

unit before after
nayuta -15.3% -44.3%
mast-romantic-maid — -59.0%
snow-white-heavy-arms -34.4% -53.0%
neon-vision-eye -49.3% -48.7%
little-mermaid -5.4% -22.8%

Census: 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
scaleBlocks early-returns, so the regression snapshot is unchanged and was never regenerated.
verify.sh green (5041 tests).

Guards

  • An anchor that is not a real max-level entry is a structural error, not a silent fallback.
  • The kind→scalable-fields map is owned and exported by scale.ts and imported by the
    validator — 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 changed
    rather than mirroring its switch. --sim <slug> sizes per-slot sensitivity.
  • scripts/apply-level-scale.ts is the declarative, idempotent applier; every row carries the
    note/kit quote it rests on. The census's ← 60 × 12 hint is a brute-force table search that
    finds coincidences — eve's 720% is really 240 × 3 per her note, and
    snow-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 in
scratchpad/gates/2026-08-25-skill-level-scaling/.

  • Round 1: FIX-BEFORE-MERGE — 1 BLOCKER (perResource.mult, which also meant this branch's
    published mihara-bonding-chain figure was understated), 3 FIX, 3 NOTE. All confirmed and fixed.
  • Round 2: CLEAN — its 1 FOLLOW-UP + 2 NOTEs were also fixed rather than filed.

Merge with origin/main (PR #147)

Both conflicts resolved to upstream, and the changes converge: mihara-bonding-chain's
500.6 dump is now ten paced blocks of a literal 50.06 (native table match) and her burst DoT is
perResource {mirror, 50.05} — covered by the perResource.mult fix. Her two apply-table rows were
removed; she scales with no annotation.

Deliberately NOT enacted (queued)

  1. 19 TREASURE units cannot scale at all — a DATA gap. data/skill-levels.json holds the BASE
    kit arrays while those overrides model the treasure kit. drake: table 11.85/1254/98.55 vs
    override 20.09/3009.6/201.6; boosts are non-uniform (×1.70, ×2.40, one line unboosted), and
    blablalink 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.
  2. Cross-slot anchors — ein skill1 363.24 = 4 × 90.81 with 90.81 in her skill2 table
    (also eve, neon-vision-eye, emma-tactical-upgrade, red-hood). May indicate those blocks
    are 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

Infernal-Crack-LED and others added 11 commits August 25, 2026 02:31
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>
@Infernal-Crack-LED
Infernal-Crack-LED merged commit 718d679 into main Aug 25, 2026
1 check passed
@Infernal-Crack-LED
Infernal-Crack-LED deleted the skill-level-scaling branch August 25, 2026 11:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant