feat: NTE gear optimizer web app

Client-side optimizer for Neverness to Everness console builds. Imports a
gear export from nte-history-exporter, keeps it in IndexedDB, and solves for
the best module placement per character or across a team.

- domain/: pure ports of the Python research scripts (shapes, cartridges,
  board tiling, stats, scoring, set bonuses, Arc effects, sheet prediction).
  No DOM, no storage imports, so they stay worker-safe and Node-testable.
- solver/: single-character solve with branch and bound, plus a three-phase
  team solve (portfolio, leximin, column generation) over disjoint item sets.
  Runs in a Worker when one can be constructed, inline otherwise.
- db/: three storage tiers selected by use rather than feature detection,
  fail-closed import validation, and undo that restores both sides of a steal.
- ui/: React 19 views for items, characters and teams; the board is drawn as
  the deliverable rather than a score.
- Two build targets: a hosted bundle and a single self-contained nte.html
  with no external requests.

Values the model cannot measure are reported as unavailable rather than
guessed: cartridge set bonuses, the level 80 base-stat multiplier, and the
distinction between "optimal for this packing" and globally optimal.

145 tests, tsc clean, both build targets clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goober 2026-08-21 11:38:17 +03:00
commit 91952b1745
157 changed files with 92600 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
*.local
.DS_Store
.tsbuild/
# Build output; the deploy copies it, git does not carry it.
dist/

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22.23.2

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# nte-optimizer
Gear optimizer for *Neverness to Everness*. Static site, no server, no account.
It imports the JSON that [`nte-exporter`](../nte-exporter) writes during a normal
capture run, and recommends what to equip on whom — drawn onto each character's
console grid.
## Running it
```sh
npm ci
npm run dev # development
npm run build # dist/index.html — the normal build
npm run build:single # dist/nte.html — one file, works over file://
npm test
```
`dist/` is **committed on purpose**. In three years, when `npm ci` fails on some
transitive native module, `python3 -m http.server -d dist` will still work. The
source build is the convenience; the committed output is the artifact.
## Layout
| path | what lives there |
|---|---|
| `data-src/` | raw everness mirrors, Arc classifications, guide data. Committed, not shipped. |
| `tools/` | build-time scripts. Output goes to `src/generated/`, also committed. |
| `src/domain/` | **pure.** No DOM, no IndexedDB, no imports from `ui/`, `db/` or `state/`. |
| `src/solver/` | same rule. Chunked generators, so they run in a worker or inline unchanged. |
| `src/db/` | schema, migrations, import, persistence adapters. |
| `src/ui/` | React. Four tabs and a hash router. |
The `domain/` and `solver/` rule is what makes them worker-safe, Node-testable
and diffable against the Python they were ported from. Keep it.
## Two things that are easy to get wrong
**Ported rules were expensive to derive and break in ways that still look
plausible.** Before touching anything in `domain/`, read
`~/Apps/nte-research/WIREFORMAT.md` and the "facts worth not re-deriving"
section of `RESUME.md`. Builds are 6, 7 or 8 modules and never always 7; the
console trait is not always CRIT DMG or Type III; a transposed board renders
correctly on a symmetric grid.
**The model has measured gaps, and the app is built to show them rather than
hide them.** Set bonus values are unknown and contribute nothing. The base-stat
multiplier has never been read at level 80. A `proven` build is optimal for its
packing and cartridge, not globally. Every one of those is surfaced in the UI on
purpose — if you find yourself replacing one with a plausible number, don't.
## Degraded modes
Opening `dist/nte.html` from the filesystem gives the page an opaque origin, so
IndexedDB, workers, `fetch` and module scripts are all unavailable. The app
handles this behind two interfaces it never sees through — `PersistenceAdapter`
(IndexedDB → localStorage → memory) and `SolverHost` (worker → inline) — and
shows a banner when storage is not durable. Artwork is not carried in that build.

51
data-src/arc_effects.json Normal file
View file

@ -0,0 +1,51 @@
{
"fork_appliance": {"name": "Real Music", "effects": [{"placeholder": 0, "stat": null, "mode": "unmodellable", "why": "Redirect Skill DMG is named ability damage, not a sheet stat."}]},
"fork_vine": {"name": "Be Happy", "effects": [{"placeholder": 0, "stat": "HP", "percent": true, "mode": "toggle", "default": false, "why": "Heal-on-kill is conditional HP recovery for wearer."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_dustbin": {"name": "Dangerous Game", "effects": [{"placeholder": 0, "stat": "Break Intensity", "mode": "toggle", "default": false, "why": "Conditional Break Intensity while reducing Break."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Buff duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_nonos": {"name": "First Step to Success", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Conditional ATK buff after Redirect Skill."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Buff duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_Prokaryon": {"name": "Us.", "effects": [{"placeholder": 0, "stat": null, "mode": "unmodellable", "why": "Basic Attack damage is named ability damage, not a sheet stat."}]},
"fork_BitGame": {"name": "Blow up the Crowd", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Off-field ATK buff is conditional."}, {"placeholder": 1, "stat": "ATK", "percent": true, "mode": "stacks", "max_stacks": 4, "default_stacks": 0, "why": "ATK gained per damage trigger stacks to 4."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Trigger interval in seconds, not a stat."}, {"placeholder": 3, "stat": null, "mode": "toggle", "default": false, "why": "Psyche DMG is ability/element damage, not a sheet stat."}, {"placeholder": 4, "stat": null, "mode": "stacks", "max_stacks": 10, "default_stacks": 0, "why": "Named ability damage stacker; not modelled as sheet stat."}]},
"fork_LunarPhase": {"name": "Blushing Mirage", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK always active."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Cosmos DMG is element damage, not unconditional sheet stat."}, {"placeholder": 2, "stat": "DEF", "percent": true, "mode": "toggle", "default": false, "why": "DEF ignore is a conditional defensive modifier."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_jingmotingyuan": {"name": "Camellia Society", "effects": [{"placeholder": 0, "stat": "CRIT DMG", "percent": true, "mode": "stacks", "max_stacks": 4, "default_stacks": 0, "why": "Crit DMG stacks when HP is reduced without damage."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Stack duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Self-HP cost, not a stat."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}, {"placeholder": 4, "stat": null, "mode": "unmodellable", "why": "Incantation DMG is named attack damage, not a sheet stat."}, {"placeholder": 5, "stat": null, "mode": "unmodellable", "why": "Incantation DMG is named attack damage, not a sheet stat."}]},
"fork_mamen": {"name": "Contemplative Cat", "effects": [{"placeholder": 0, "stat": null, "mode": "unmodellable", "why": "Fons threshold only gates the effect; not a sheet stat."}, {"placeholder": 1, "stat": null, "mode": "stacks", "max_stacks": 10, "default_stacks": 0, "why": "Cosmos DMG is element damage, stacked per Fons threshold."}]},
"fork_rishi": {"name": "Day Off", "effects": [{"placeholder": 0, "stat": "Charge Efficiency", "percent": true, "mode": "always", "why": "Flat Charge Efficiency bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Ultimate Energy restore amount is not a sheet stat."}, {"placeholder": 3, "stat": null, "mode": "stacks", "max_stacks": 5, "default_stacks": 0, "why": "Count of restores, not a sheet stat."}, {"placeholder": 4, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_Arachne": {"name": "Eternal Waltz", "effects": [{"placeholder": 0, "stat": "HP", "percent": true, "mode": "always", "why": "Flat Max HP bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Mental DMG is element/ability damage, not a sheet stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_BlastCandy": {"name": "Fluff of Fearlessness", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "ATK buff after Ultimate, conditional."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_KnightCandy": {"name": "Fluff of Ferocity", "effects": [{"placeholder": 0, "stat": "CRIT DMG", "percent": true, "mode": "stacks", "max_stacks": 10, "default_stacks": 0, "why": "Crit DMG stacks on crit hits."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_ThiefCandy": {"name": "Fluff of Finesse", "effects": [{"placeholder": 0, "stat": "Universal DMG%", "percent": true, "mode": "stacks", "max_stacks": 3, "default_stacks": 0, "why": "Damage bonus is universal and stacks on Critical Dodge."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_MotorCandy": {"name": "Fluff of Fleetness", "effects": [{"placeholder": 0, "stat": null, "mode": "duration", "why": "1s tick interval, not a stat."}, {"placeholder": 1, "stat": "ATK", "percent": true, "mode": "stacks", "max_stacks": 5, "default_stacks": 0, "why": "ATK stacks while active up to 5."}]},
"fork_BoxingCandy": {"name": "Fluff of Fortitude", "effects": [{"placeholder": 0, "stat": "Universal DMG%", "percent": true, "mode": "toggle", "default": false, "why": "DMG bonus is universal and conditionally higher below 50% HP."}, {"placeholder": 1, "stat": "Universal DMG%", "percent": true, "mode": "toggle", "default": false, "why": "Higher conditional tier of same DMG bonus."}]},
"fork_mofeikesi": {"name": "Good Boy's Grand Adventure", "effects": [{"placeholder": 0, "stat": "Charge Efficiency", "percent": true, "mode": "always", "why": "Flat Charge Efficiency bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 2, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Team ATK buff after Ultimate."}, {"placeholder": 3, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Extra team ATK bonus if Ultimate controls enemies."}]},
"fork_PoliceRat": {"name": "Hethereau's Keeper", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "unmodellable", "why": "Boss damage is target-specific damage, not a sheet stat."}, {"placeholder": 2, "stat": null, "mode": "unmodellable", "why": "Summon damage based on ATK is not a sheet stat."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_Time": {"name": "Marching Beyond Time", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": "Ultimate Crit DMG", "percent": true, "mode": "stacks", "max_stacks": 3, "default_stacks": 0, "why": "Ultimate Crit DMG consumes Wastetime stacks."}, {"placeholder": 2, "stat": null, "mode": "stacks", "max_stacks": 3, "default_stacks": 0, "why": "Wastetime count is a trigger resource, not a sheet stat."}, {"placeholder": 3, "stat": "DEF", "percent": true, "mode": "toggle", "default": false, "why": "DEF ignore is conditional on consuming 3 stacks."}, {"placeholder": 4, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_wushoutieyu": {"name": "Raging Flames", "effects": [{"placeholder": 0, "stat": null, "mode": "toggle", "default": false, "why": "Redirect Skill/Ultimate DMG is named ability damage, not a sheet stat."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "stacks", "max_stacks": 2, "default_stacks": 0, "why": "Stack count for named ability damage, not a sheet stat."}, {"placeholder": 3, "stat": null, "mode": "toggle", "default": false, "why": "Lakshana DMG is element damage, not always-on sheet stat."}]},
"fork_DemonBlade": {"name": "Ravenous Blade", "effects": [{"placeholder": 0, "stat": "CRIT Rate", "percent": true, "mode": "always", "why": "Flat Crit Rate while equipped."}, {"placeholder": 1, "stat": "CRIT DMG", "percent": true, "mode": "stacks", "max_stacks": 7, "default_stacks": 0, "why": "Crit DMG stacks when dealing Incantation DMG."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_TigerTally": {"name": "Ready-Ready", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "unmodellable", "why": "Basic Attack/Critical Riposte damage is named ability damage."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 4, "stat": null, "mode": "unmodellable", "why": "Boss damage is target-specific damage, not a sheet stat."}, {"placeholder": 5, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_Butterfly": {"name": "Reality Refuge", "effects": [{"placeholder": 0, "stat": null, "mode": "toggle", "default": false, "why": "Anima DMG is element damage, not a sheet stat."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Attachment DMG is named ability damage, not a sheet stat."}, {"placeholder": 2, "stat": null, "mode": "toggle", "default": false, "why": "Higher conditional tier of same named ability damage."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_Whale": {"name": "Song of the Whale", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Damage to Broken enemies is conditional target damage."}, {"placeholder": 2, "stat": "HP", "percent": true, "mode": "toggle", "default": false, "why": "HP recovery on Broken enemy defeat."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_moon": {"name": "Stellar Veil", "effects": [{"placeholder": 0, "stat": null, "mode": "toggle", "default": false, "why": "Psyche DMG is element damage, not a sheet stat."}, {"placeholder": 1, "stat": "CRIT DMG", "percent": true, "mode": "stacks", "max_stacks": 10, "default_stacks": 0, "why": "Crit DMG stacks on Psyche DMG hits."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_NestBird": {"name": "Tears Beneath the Mask", "effects": [{"placeholder": 0, "stat": null, "mode": "unmodellable", "why": "Damage reduction on marked enemies is not a wearer sheet stat."}]},
"fork_Rose": {"name": "The Last Rose", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": "CRIT DMG", "percent": true, "mode": "stacks", "max_stacks": 10, "default_stacks": 0, "why": "Crit DMG stacks from Chaos Thorn."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Stack duration in seconds, not a stat."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Duration of immediate stack grant, not a stat."}]},
"fork_worldrain": {"name": "The Rain That Shook the World", "effects": [{"placeholder": 0, "stat": null, "mode": "toggle", "default": false, "why": "Cosmos DMG is element damage, not a sheet stat."}, {"placeholder": 1, "stat": "Cycle Intensity", "mode": "toggle", "default": false, "why": "Flat Cycle Intensity bonus after Redirect Skill."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_Door": {"name": "The Wrong Gate", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Anima DMG is element damage, not a sheet stat."}, {"placeholder": 2, "stat": "Universal DMG%", "percent": true, "mode": "toggle", "default": false, "why": "All allies' DMG is a universal damage buff."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_GoldRecord": {"name": "Voice of the Voyager", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "always", "why": "Flat ATK bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "unmodellable", "why": "Support Skill Crit DMG is named ability damage."}, {"placeholder": 2, "stat": null, "mode": "stacks", "max_stacks": 3, "default_stacks": 0, "why": "Ultimate Crit DMG stacks from Support Skill damage."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_GoldWool": {"name": "What's Desired", "effects": [{"placeholder": 0, "stat": null, "mode": "toggle", "default": false, "why": "Lakshana DMG is element damage, not a sheet stat."}, {"placeholder": 1, "stat": "CRIT DMG", "percent": true, "mode": "toggle", "default": false, "why": "Conditional Crit DMG buff after Redirect Skill or Ultimate."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_Nakupeda": {"name": "Your Happiness is Priceless", "effects": [{"placeholder": 0, "stat": "HP", "percent": true, "mode": "always", "why": "Flat HP bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Team healing/Shield/HP effects are not wearer sheet stats."}, {"placeholder": 2, "stat": null, "mode": "toggle", "default": false, "why": "Shield amount is not a wearer sheet stat."}, {"placeholder": 3, "stat": null, "mode": "duration", "why": "Shield duration in seconds, not a stat."}, {"placeholder": 4, "stat": null, "mode": "toggle", "default": false, "why": "Team HP recovery is not a wearer sheet stat."}, {"placeholder": 5, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_BlackBook": {"name": "Youthful Fantasy", "effects": [{"placeholder": 0, "stat": "Break Intensity", "mode": "always", "why": "Flat Break Intensity bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Chain count is a trigger resource, not a stat."}, {"placeholder": 3, "stat": null, "mode": "toggle", "default": false, "why": "Chaos DMG is element damage, not a sheet stat."}, {"placeholder": 4, "stat": null, "mode": "unmodellable", "why": "Break Damage scaling is not a wearer sheet stat."}]},
"fork_koinobori": {"name": "A Time Will Come", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Conditional team ATK bonus when team comp requirement is met."}, {"placeholder": 1, "stat": "DEF", "percent": true, "mode": "toggle", "default": false, "why": "Conditional team DEF bonus when team comp requirement is met."}, {"placeholder": 2, "stat": "HP", "percent": true, "mode": "toggle", "default": false, "why": "Conditional team HP bonus when team comp requirement is met."}]},
"fork_Castle": {"name": "Call of the Twisted City", "effects": [{"placeholder": 0, "stat": "Healing Bonus", "percent": true, "mode": "toggle", "default": false, "why": "Conditional Healing Bonus after Redirect Skill."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_PaperPlane": {"name": "Clear Skies", "effects": [{"placeholder": 0, "stat": null, "mode": "unmodellable", "why": "Redirect Skill/Ultimate DMG is named ability damage, not a sheet stat."}]},
"fork_bopu": {"name": "Cosmos Daze, Wild Reverie", "effects": [{"placeholder": 0, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 1, "stat": "Universal DMG%", "percent": true, "mode": "toggle", "default": false, "why": "Damage dealt bonus is universal and conditional after Support Skill."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_yaodao": {"name": "Drawn Blade", "effects": [{"placeholder": 0, "stat": null, "mode": "unmodellable", "why": "Incantation DMG based on ATK is named ability damage."}]},
"fork_spider": {"name": "Failing You, Heavy in My Heart", "effects": [{"placeholder": 0, "stat": null, "mode": "stacks", "max_stacks": 8, "default_stacks": 0, "why": "Stack count for Spider Knowledge resource, not a sheet stat."}, {"placeholder": 1, "stat": "ATK", "percent": true, "mode": "stacks", "max_stacks": 8, "default_stacks": 0, "why": "Team ATK buff per stack consumed on Ultimate."}, {"placeholder": 2, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Conditional extra ATK when 8 stacks are consumed."}]},
"fork_lingganzhongjiezhe": {"name": "Mind Royale", "effects": [{"placeholder": 0, "stat": null, "mode": "duration", "why": "Ultimate Energy amount is not a sheet stat."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_oulaquantao": {"name": "Oraora!", "effects": [{"placeholder": 0, "stat": null, "mode": "duration", "why": "Stack duration in seconds, not a stat."}, {"placeholder": 1, "stat": null, "mode": "stacks", "max_stacks": 10, "default_stacks": 0, "why": "Basic Attack DMG is named ability damage, not a sheet stat."}]},
"fork_jiaojuan": {"name": "Shiny Days", "effects": [{"placeholder": 0, "stat": "Break Intensity", "mode": "always", "why": "Flat Break Intensity bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Damage against Broken units is target-specific damage, not a sheet stat."}]},
"fork_snowman": {"name": "The Fools' Spring", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Conditional ATK buff while shielded."}]},
"fork_wuhuakuang": {"name": "The Forgotten", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Conditional ATK buff above 50% HP."}, {"placeholder": 1, "stat": "DEF", "percent": true, "mode": "toggle", "default": false, "why": "Conditional DEF buff below 50% HP."}]},
"fork_BitterCake": {"name": "The Good, The Bad, The Bitter", "effects": [{"placeholder": 0, "stat": "DEF", "percent": true, "mode": "toggle", "default": false, "why": "Conditional DEF buff when taking damage."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Cooldown in seconds, not a stat."}]},
"fork_tuansanlang": {"name": "The Great Thief", "effects": [{"placeholder": 0, "stat": "Break Intensity", "mode": "toggle", "default": false, "why": "Conditional team Break Intensity buff for same Esper Type."}]},
"fork_Crowbar": {"name": "Time Bandit", "effects": [{"placeholder": 0, "stat": "Break Intensity", "mode": "toggle", "default": false, "why": "Conditional Break Intensity buff after Redirect Skill."}, {"placeholder": 1, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]},
"fork_yuren": {"name": "Umbrella", "effects": [{"placeholder": 0, "stat": "HP", "percent": true, "mode": "always", "why": "Flat HP bonus while equipped."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Shield strength is not a wearer sheet stat."}]},
"fork_Kite": {"name": "Watch Your Heads!", "effects": [{"placeholder": 0, "stat": "ATK", "percent": true, "mode": "toggle", "default": false, "why": "Conditional ATK buff after Redirect Skill."}, {"placeholder": 1, "stat": null, "mode": "toggle", "default": false, "why": "Lakshana DMG is element damage, not a sheet stat."}, {"placeholder": 2, "stat": null, "mode": "duration", "why": "Duration in seconds, not a stat."}]}
}

14708
data-src/arcs.json Normal file

File diff suppressed because it is too large Load diff

66951
data-src/espers.json Normal file

File diff suppressed because it is too large Load diff

13
data-src/guides.json Normal file
View file

@ -0,0 +1,13 @@
{
"format": "nte-guides",
"format_version": 1,
"_source": "Prydwen build guides, entered by hand and reviewed before committing.",
"_why_not_scraped": "Prydwen's character pages are client-rendered, so a plain fetch returns only navigation chrome, and their Gatsby /page-data/ endpoint answers 410 Gone. Their robots.txt does allow content pages with a 10 second crawl-delay, so a headless render would be permitted - it is just not something the build can do unattended. Entries below are typed in from the published guide.",
"_shape": {
"key": "the GA_<key>_* ability key, so it joins to a capture directly",
"variants": "one per published priority set (Main DPS, SubDPS, Break, DoT)",
"targets": "recommended endgame stats, raw stat ids, percentages as fractions",
"priority": "substat ranking, best first; weights are derived from position"
},
"characters": []
}

127
data-src/set-bonuses.json Normal file
View file

@ -0,0 +1,127 @@
{
"format": "nte-set-bonuses",
"format_version": 1,
"note": "Bonus VALUES are not in any capture and not on everness: items.json carries only the cartridge boxes. Each tier stays unknown until read in game or scraped, and an unknown tier contributes nothing rather than a guessed number.",
"sets": {
"Crimson: Twin Butterflies": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Devil's Blood: Curse": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Diabolos": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Fireflies and the Forest": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Kingdom's Guard": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Lost Radiance": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Quiet Manor": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Shadow Creed": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Speedy Hedgehog": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Street Boxer": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Thea's Night Tavern": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
},
"Tiny Big Adventure": {
"2": {
"unknown": true,
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}
}
}

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NTE gear optimizer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1322
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "nte-optimizer",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Gear optimizer for Neverness to Everness. Static, no server.",
"scripts": {
"dev": "vite",
"build": "npm run generate && tsc -b && vite build",
"build:single": "npm run build && vite build --config vite.single.config.ts",
"preview": "vite preview",
"serve:dist": "python3 -m http.server -d dist",
"generate": "npm run gen:gamedata && npm run gen:tilings",
"gen:gamedata": "node --experimental-strip-types tools/prune-gamedata.ts",
"gen:tilings": "node --experimental-strip-types tools/precompute-tilings.ts",
"gen:icons": "node --experimental-strip-types tools/fetch-icons.ts",
"bench": "node --experimental-strip-types tools/bench-solve.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc -b --noEmit",
"check:guides": "node --experimental-strip-types tools/check-guides.ts"
},
"dependencies": {
"idb": "8.0.3",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/node": "26.2.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.1.0",
"typescript": "5.9.3",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

161
src/db/adapters/idb.ts Normal file
View file

@ -0,0 +1,161 @@
/**
* The normal tier.
*
* One database, integer-versioned, with explicit migrations in `upgrade`.
*
* The rule that governs every write here: **an IndexedDB transaction
* auto-commits the moment you await anything that is not an IndexedDB request.**
* So each transaction body below is a straight run of `store.clear()` and
* `store.put()` calls with a single await on `tx.done` at the end. Nothing else
* may creep in - not a `structuredClone`, not a progress callback - or the
* transaction closes underneath the remaining writes and the import lands half
* applied.
*/
import { openDB, type IDBPDatabase } from "idb";
import {
DB_NAME,
DB_VERSION,
ITEM_KIND_INDEX,
STORES,
UNDO_LIMIT,
type EquipmentRow,
type UndoRow,
} from "../schema.ts";
import { emptyState, type PersistenceAdapter, type StoredState } from "./types.ts";
const SNAPSHOT_KEY = "preImport";
export async function openDatabase(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
// Migrations are explicit and additive; each `if` is one version step.
if (oldVersion < 1) {
const items = db.createObjectStore(STORES.items, { keyPath: "instance" });
items.createIndex(ITEM_KIND_INDEX, "kind");
// Keyed on `instance`: an item is worn in exactly one place, and the
// primary key is what enforces it.
db.createObjectStore(STORES.equipment, { keyPath: "instance" });
db.createObjectStore(STORES.characters, { keyPath: "characterId" });
db.createObjectStore(STORES.ownerNames, { keyPath: "ownerGroup" });
db.createObjectStore(STORES.undo, { keyPath: "id", autoIncrement: true });
db.createObjectStore(STORES.snapshots);
db.createObjectStore(STORES.meta, { keyPath: "key" });
}
},
});
}
export function createIdbAdapter(db: IDBPDatabase): PersistenceAdapter {
return {
kind: "idb",
durable: true,
async read(): Promise<StoredState> {
const [items, equipment, characters, ownerNames, meta, undo, snapshot] =
await Promise.all([
db.getAll(STORES.items),
db.getAll(STORES.equipment),
db.getAll(STORES.characters),
db.getAll(STORES.ownerNames),
db.getAll(STORES.meta),
db.getAll(STORES.undo),
db.get(STORES.snapshots, SNAPSHOT_KEY),
]);
const state = emptyState();
state.db = { items, equipment, characters, ownerNames, meta };
state.undo = undo as UndoRow[];
state.snapshot = (snapshot as StoredState["snapshot"]) ?? null;
return state;
},
async replaceAll(next: StoredState): Promise<void> {
const tx = db.transaction(
[
STORES.items,
STORES.equipment,
STORES.characters,
STORES.ownerNames,
STORES.undo,
STORES.snapshots,
STORES.meta,
],
"readwrite",
);
// Synchronous from here to `tx.done`.
const snapshots = tx.objectStore(STORES.snapshots);
if (next.snapshot) void snapshots.put(next.snapshot, SNAPSHOT_KEY);
else void snapshots.delete(SNAPSHOT_KEY);
const items = tx.objectStore(STORES.items);
void items.clear();
for (const row of next.db.items) void items.put(row);
const equipment = tx.objectStore(STORES.equipment);
void equipment.clear();
for (const row of next.db.equipment) void equipment.put(row);
const characters = tx.objectStore(STORES.characters);
void characters.clear();
for (const row of next.db.characters) void characters.put(row);
// Never touched by import - the player named these once, and the values
// they key on are stable across exports.
const ownerNames = tx.objectStore(STORES.ownerNames);
void ownerNames.clear();
for (const row of next.db.ownerNames) void ownerNames.put(row);
const undo = tx.objectStore(STORES.undo);
void undo.clear();
for (const row of next.undo.slice(-UNDO_LIMIT)) void undo.put(row);
const meta = tx.objectStore(STORES.meta);
void meta.clear();
for (const row of next.db.meta) void meta.put(row);
await tx.done;
},
async writeEquipment(rows: EquipmentRow[], undoRows: UndoRow[]): Promise<void> {
const tx = db.transaction([STORES.equipment, STORES.undo], "readwrite");
const equipment = tx.objectStore(STORES.equipment);
void equipment.clear();
for (const row of rows) void equipment.put(row);
const undo = tx.objectStore(STORES.undo);
void undo.clear();
for (const row of undoRows.slice(-UNDO_LIMIT)) void undo.put(row);
await tx.done;
},
close() {
db.close();
},
};
}
/**
* IndexedDB, then `localStorage`, then memory.
*
* Each tier is tried by *using* it, not by feature detection: on a `file://`
* page `indexedDB` is a defined global that throws on `open`, and `localStorage`
* can be present but throw on `setItem`.
*/
export async function createBestAdapter(): Promise<PersistenceAdapter> {
try {
return createIdbAdapter(await openDatabase());
} catch {
// Fall through.
}
try {
const storage = globalThis.localStorage;
const probe = "nte-optimizer:probe";
storage.setItem(probe, "1");
storage.removeItem(probe);
const { createLocalStorageAdapter } = await import("./local-storage.ts");
return createLocalStorageAdapter(storage);
} catch {
// Fall through.
}
const { createMemoryAdapter } = await import("./memory.ts");
return createMemoryAdapter();
}

View file

@ -0,0 +1,74 @@
/**
* The `file://` tier.
*
* Chrome gives a `file://` document an opaque origin, so IndexedDB is gone but
* `localStorage` sometimes survives. It is synchronous and small, so the whole
* database is one key and the snapshot is another - a snapshot is about a
* megabyte, which is most of the budget, so it is dropped first when the quota
* is hit rather than failing the write.
*/
import { emptyState, type PersistenceAdapter, type StoredState } from "./types.ts";
import type { EquipmentRow, UndoRow } from "../schema.ts";
export const STATE_KEY = "nte-optimizer:state";
export const SNAPSHOT_KEY = "nte-optimizer:snapshot";
export interface StorageLike {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
}
export function createLocalStorageAdapter(storage: StorageLike): PersistenceAdapter {
const readState = (): StoredState => {
const raw = storage.getItem(STATE_KEY);
if (!raw) return emptyState();
try {
const parsed = JSON.parse(raw) as StoredState;
const snapshotRaw = storage.getItem(SNAPSHOT_KEY);
parsed.snapshot = snapshotRaw ? (JSON.parse(snapshotRaw) as StoredState["db"]) : null;
return parsed;
} catch {
// A corrupt value is not worth a crash on boot; start clean.
return emptyState();
}
};
const write = (state: StoredState): void => {
const { snapshot, ...rest } = state;
try {
storage.setItem(STATE_KEY, JSON.stringify({ ...rest, snapshot: null }));
} catch (error) {
// Out of quota: the snapshot is the largest thing here and the least
// essential, so it goes before the database does.
storage.removeItem(SNAPSHOT_KEY);
storage.setItem(STATE_KEY, JSON.stringify({ ...rest, snapshot: null }));
throw error;
}
if (snapshot) {
try {
storage.setItem(SNAPSHOT_KEY, JSON.stringify(snapshot));
} catch {
storage.removeItem(SNAPSHOT_KEY);
}
} else {
storage.removeItem(SNAPSHOT_KEY);
}
};
return {
kind: "localStorage",
durable: false,
async read() {
return readState();
},
async replaceAll(next) {
write(next);
},
async writeEquipment(equipment: EquipmentRow[], undo: UndoRow[]) {
const state = readState();
write({ ...state, db: { ...state.db, equipment }, undo });
},
close() {},
};
}

21
src/db/adapters/memory.ts Normal file
View file

@ -0,0 +1,21 @@
/** Last resort: the state lives only as long as the tab does. */
import { emptyState, type PersistenceAdapter, type StoredState } from "./types.ts";
import type { EquipmentRow, UndoRow } from "../schema.ts";
export function createMemoryAdapter(initial?: StoredState): PersistenceAdapter {
let state = initial ?? emptyState();
return {
kind: "memory",
durable: false,
async read() {
return structuredClone(state);
},
async replaceAll(next) {
state = structuredClone(next);
},
async writeEquipment(equipment: EquipmentRow[], undo: UndoRow[]) {
state = structuredClone({ ...state, db: { ...state.db, equipment }, undo });
},
close() {},
};
}

40
src/db/adapters/types.ts Normal file
View file

@ -0,0 +1,40 @@
/**
* Where the database actually lives.
*
* Three tiers, one interface: IndexedDB normally, `localStorage` when a
* `file://` document's opaque origin denies IndexedDB, and memory when even that
* is refused. The app never sees through this - it asks for the state and hands
* back a new one - so the degraded modes cost nothing above the adapter beyond a
* banner telling the player their data is not durable.
*/
import type { Database, EquipmentRow, UndoRow } from "../schema.ts";
export interface StoredState {
db: Database;
undo: UndoRow[];
/** The last pre-import dump. One click back from a wrong file. */
snapshot: Database | null;
}
export interface PersistenceAdapter {
readonly kind: "idb" | "localStorage" | "memory";
/** False in the degraded tiers, where the UI must say so. */
readonly durable: boolean;
read: () => Promise<StoredState>;
/** Replace everything atomically: import, or a database restore. */
replaceAll: (state: StoredState) => Promise<void>;
/**
* The frequent path. Equipping changes equipment and undo and nothing else,
* so rewriting 817 item rows for it would be pure waste.
*/
writeEquipment: (equipment: EquipmentRow[], undo: UndoRow[]) => Promise<void>;
close: () => void;
}
export function emptyState(): StoredState {
return {
db: { items: [], equipment: [], characters: [], ownerNames: [], meta: [] },
undo: [],
snapshot: null,
};
}

106
src/db/dbfile.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* Database export and import - the "backend" without a backend.
*
* Deliberately separate from importing a game capture and labelled that way in
* the UI: a capture is the game telling you what you own, this is your whole
* workspace including everything you configured by hand. It is backup, moving
* between machines, sharing a snapshot - and it is the only persistence story
* the `file://` build has, where IndexedDB does not exist.
*/
import { DB_VERSION } from "./schema.ts";
import { emptyState, type StoredState } from "./adapters/types.ts";
export const DBFILE_FORMAT = "nte-optimizer-db";
export const DBFILE_VERSION = 1;
export interface DbFile {
format: string;
format_version: number;
/** Schema version the rows were written against. */
schema_version: number;
/** Which generated game data was in play, so a mismatch can be reported. */
gamedata_version: string | null;
exported_at: number;
state: StoredState;
}
export function exportDatabase(
state: StoredState,
gamedataVersion: string | null,
now = Date.now,
): DbFile {
return {
format: DBFILE_FORMAT,
format_version: DBFILE_VERSION,
schema_version: DB_VERSION,
gamedata_version: gamedataVersion,
exported_at: now(),
state: structuredClone(state),
};
}
export function fileName(now = new Date()): string {
const date = now.toISOString().slice(0, 10);
return `nte-db-${date}.json`;
}
export interface DbFileImport {
ok: boolean;
problems: string[];
state?: StoredState;
/** True when the file was written against an older schema. */
migrated: boolean;
}
/**
* Read a database file back.
*
* A file from a *newer* schema is refused rather than partially understood: the
* rows may carry fields this build would drop on the next write, and silently
* discarding the player's configuration is worse than refusing to open it.
*/
export function importDatabase(raw: unknown): DbFileImport {
const problems: string[] = [];
if (typeof raw !== "object" || raw === null) {
return { ok: false, problems: ["not an object"], migrated: false };
}
const file = raw as Partial<DbFile>;
if (file.format !== DBFILE_FORMAT) {
return { ok: false, problems: [`not a database file: ${String(file.format)}`], migrated: false };
}
if (file.format_version !== DBFILE_VERSION) {
return {
ok: false,
problems: [`unsupported file version ${String(file.format_version)}`],
migrated: false,
};
}
const schema = file.schema_version ?? 0;
if (schema > DB_VERSION) {
return {
ok: false,
problems: [`written by a newer build (schema ${schema} > ${DB_VERSION})`],
migrated: false,
};
}
if (typeof file.state !== "object" || file.state === null) {
return { ok: false, problems: ["no state"], migrated: false };
}
const base = emptyState();
const state = file.state as Partial<StoredState>;
const merged: StoredState = {
db: { ...base.db, ...(state.db ?? {}) },
undo: state.undo ?? [],
snapshot: state.snapshot ?? null,
};
for (const key of ["items", "equipment", "characters", "ownerNames", "meta"] as const) {
if (!Array.isArray(merged.db[key])) {
problems.push(`${key} is not a list`);
}
}
if (problems.length > 0) return { ok: false, problems, migrated: false };
return { ok: true, problems, state: merged, migrated: schema < DB_VERSION };
}

306
src/db/import.ts Normal file
View file

@ -0,0 +1,306 @@
/**
* Turning a gear export into rows.
*
* **Fail closed.** Any error at all and nothing is written: a partially imported
* account is worse than a refused import, because the player cannot tell which
* half is stale. The report is diff-shaped on purpose - "817 expected, 811
* parsed, 6 rejected" tells you something is wrong with the capture; a silent
* 811 does not.
*
* This module is pure. It parses, validates and normalises; it never touches
* IndexedDB, so it runs in a worker and in a test unchanged.
*/
import { SET_IDS } from "../domain/cartridges.ts";
import { SHAPES } from "../domain/shapes.ts";
import { isKnownStat } from "../domain/statvec.ts";
import {
emptyCharacter,
type CharacterRow,
type EquipmentRow,
type ItemRow,
} from "./schema.ts";
export const EXPORT_FORMAT = "nte-gear-export";
export const SUPPORTED_FORMAT_VERSION = 1;
export interface ImportProblem {
code: string;
detail: string;
/** The item or character it concerns, when there is one. */
subject?: string;
}
export interface ImportReport {
/** How many item records the export claimed, from its own `scan` block. */
expected: number | null;
parsed: number;
rejected: number;
problems: ImportProblem[];
}
export interface ImportResult {
ok: boolean;
report: ImportReport;
/** Only present when `ok` - there is nothing partial to apply. */
rows?: {
items: ItemRow[];
equipment: EquipmentRow[];
characters: CharacterRow[];
userUid: string | null;
serverId: string | null;
exporterVersion: string | null;
};
}
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function statPairs(
raw: unknown,
problems: ImportProblem[],
subject: string,
allowNull: boolean,
): Array<{ stat: string; value: number | null }> | null {
if (!Array.isArray(raw)) {
problems.push({ code: "stats_not_a_list", detail: "expected a list", subject });
return null;
}
const out: Array<{ stat: string; value: number | null }> = [];
for (const entry of raw) {
if (!isObject(entry) || typeof entry["stat"] !== "string") {
problems.push({ code: "stat_malformed", detail: "missing stat id", subject });
return null;
}
const stat = entry["stat"];
const value = entry["value"];
if (!isKnownStat(stat)) {
// A stat the model has no slot for cannot be scored, so it cannot be
// silently kept either.
problems.push({ code: "unknown_stat", detail: stat, subject });
return null;
}
if (value === null || value === undefined) {
if (!allowNull) {
problems.push({ code: "substat_without_value", detail: stat, subject });
return null;
}
out.push({ stat, value: null });
continue;
}
if (typeof value !== "number" || !Number.isFinite(value)) {
problems.push({ code: "stat_value_not_a_number", detail: stat, subject });
return null;
}
out.push({ stat, value });
}
return out;
}
export function parseGearExport(raw: unknown): ImportResult {
const problems: ImportProblem[] = [];
const fail = (report?: Partial<ImportReport>): ImportResult => ({
ok: false,
report: { expected: null, parsed: 0, rejected: 0, problems, ...report },
});
if (!isObject(raw)) return fail();
if (raw["format"] !== EXPORT_FORMAT) {
problems.push({ code: "wrong_format", detail: String(raw["format"]) });
return fail();
}
if (raw["format_version"] !== SUPPORTED_FORMAT_VERSION) {
problems.push({
code: "unsupported_format_version",
detail: String(raw["format_version"]),
});
return fail();
}
const scan = isObject(raw["scan"]) ? raw["scan"] : null;
const expected =
scan && typeof scan["cartridges"] === "number" && typeof scan["modules"] === "number"
? scan["cartridges"] + scan["modules"]
: null;
const rawItems = raw["items"];
if (!Array.isArray(rawItems)) {
problems.push({ code: "items_missing", detail: "no items list" });
return fail({ expected });
}
const items: ItemRow[] = [];
const equipment: EquipmentRow[] = [];
const seen = new Set<string>();
const at = Date.now();
const batchId = `import-${at}`;
let rejected = 0;
for (const entry of rawItems) {
if (!isObject(entry)) {
rejected += 1;
problems.push({ code: "item_not_an_object", detail: "skipped" });
continue;
}
const instance = entry["instance"];
const itemId = entry["item_id"];
const kind = entry["kind"];
const subject = typeof instance === "string" ? instance : "<no instance>";
if (typeof instance !== "string" || instance.length === 0) {
rejected += 1;
problems.push({ code: "item_without_instance", detail: String(itemId), subject });
continue;
}
if (seen.has(instance)) {
// The primary key would silently overwrite; say so instead.
rejected += 1;
problems.push({ code: "duplicate_instance", detail: instance, subject });
continue;
}
if (kind !== "module" && kind !== "cartridge") {
rejected += 1;
problems.push({ code: "unknown_kind", detail: String(kind), subject });
continue;
}
if (typeof itemId !== "string") {
rejected += 1;
problems.push({ code: "item_without_id", detail: "missing item_id", subject });
continue;
}
const shape = typeof entry["shape"] === "string" ? entry["shape"] : null;
if (kind === "module" && (shape === null || !(shape in SHAPES))) {
rejected += 1;
problems.push({ code: "unknown_shape", detail: String(shape), subject });
continue;
}
const set =
kind === "cartridge"
? (SET_IDS[itemId] ?? (typeof entry["set"] === "string" ? entry["set"] : null))
: null;
if (kind === "cartridge" && set === null) {
rejected += 1;
problems.push({ code: "unknown_set", detail: itemId, subject });
continue;
}
const before = problems.length;
const mains = statPairs(entry["main_stats"], problems, subject, true);
const subs = statPairs(entry["substats"], problems, subject, false);
if (mains === null || subs === null || problems.length !== before) {
rejected += 1;
continue;
}
const level = entry["level"];
if (typeof level !== "number" || !Number.isInteger(level) || level < 0 || level > 20) {
rejected += 1;
problems.push({ code: "bad_level", detail: String(level), subject });
continue;
}
seen.add(instance);
items.push({
instance,
kind,
itemId,
shape,
cells: shape ? SHAPES[shape as keyof typeof SHAPES].length : null,
set,
level,
rarity: typeof entry["rarity"] === "string" ? entry["rarity"] : "unknown",
mainStats: mains,
substats: subs as Array<{ stat: string; value: number }>,
});
const ownerGroup = entry["owner_group"];
if (typeof ownerGroup === "string" && ownerGroup.length > 0) {
equipment.push({
instance,
ownerGroup,
characterId: null,
cells: null,
origin: "imported",
batchId,
at,
});
}
}
const characters: CharacterRow[] = [];
const rawCharacters = raw["characters"];
if (Array.isArray(rawCharacters)) {
for (const entry of rawCharacters) {
if (!isObject(entry) || typeof entry["key"] !== "string") {
problems.push({ code: "character_malformed", detail: "missing key" });
continue;
}
const row = emptyCharacter(entry["key"]);
row.level = typeof entry["level"] === "number" ? entry["level"] : null;
row.breakthroughs =
typeof entry["breakthroughs"] === "number" ? entry["breakthroughs"] : null;
characters.push(row);
}
}
// The count from the export's own scan block is the check that catches a
// truncated capture, which no per-record validation can see.
if (expected !== null && items.length + rejected !== expected) {
problems.push({
code: "count_mismatch",
detail: `${expected} expected, ${items.length + rejected} present`,
});
}
const report: ImportReport = {
expected,
parsed: items.length,
rejected,
problems,
};
if (rejected > 0 || problems.length > 0) return { ok: false, report };
return {
ok: true,
report,
rows: {
items,
equipment,
characters,
userUid: typeof raw["user_uid"] === "string" ? raw["user_uid"] : null,
serverId: typeof raw["server_id"] === "string" ? raw["server_id"] : null,
exporterVersion: isObject(raw["exporter"])
? ((raw["exporter"]["version"] as string | undefined) ?? null)
: null,
},
};
}
/**
* Merge imported character rows onto stored ones.
*
* Level and ascension come from the game and are refreshed; everything the
* player configured - Arc, refinement, effect toggles, build variant, custom
* targets - is theirs and survives.
*/
export function mergeCharacters(
stored: readonly CharacterRow[],
imported: readonly CharacterRow[],
): CharacterRow[] {
const byId = new Map(stored.map((row) => [row.characterId, row]));
const out: CharacterRow[] = [];
for (const row of imported) {
const previous = byId.get(row.characterId);
byId.delete(row.characterId);
out.push(
previous
? { ...previous, level: row.level, breakthroughs: row.breakthroughs }
: row,
);
}
// A character the capture did not mention is still the player's; keep it.
for (const remaining of byId.values()) out.push(remaining);
return out;
}

137
src/db/schema.ts Normal file
View file

@ -0,0 +1,137 @@
/**
* The local database.
*
* One IndexedDB database, integer-versioned with explicit migrations. Treated as
* a **durable log, not a query engine**: everything is loaded into memory at
* boot - well under 5 MB at 817 items - and filtered and sorted in JS. Substat
* filters are not indexable in IndexedDB anyway, so one index carries the whole
* schema and it stops churning.
*/
export const DB_NAME = "nte-optimizer";
export const DB_VERSION = 1;
export const STORES = {
/** Cleared and replaced on import. Keyed by `instance`. */
items: "items",
/**
* Cleared and replaced on import. Keyed by `instance` too - an item is worn in
* exactly one place, so the primary key makes double-equipping structurally
* impossible rather than merely something to test for.
*/
equipment: "equipment",
/**
* Level and ascension are refreshed by import; the Arc, refinement, effect
* toggles, build variant, custom targets and priorities are preserved.
*/
characters: "characters",
/** Named once by the player and **never touched by import**. */
ownerNames: "ownerNames",
/** Bounded ring of equipment changes. Cleared by import. */
undo: "undo",
/** The last pre-import dump, so a wrong file is a click to undo. */
snapshots: "snapshots",
meta: "meta",
} as const;
export type StoreName = (typeof STORES)[keyof typeof STORES];
export const ITEM_KIND_INDEX = "by_kind";
export interface ItemRow {
instance: string;
kind: "module" | "cartridge";
itemId: string;
shape: string | null;
cells: number | null;
set: string | null;
level: number;
rarity: string;
mainStats: Array<{ stat: string; value: number | null }>;
substats: Array<{ stat: string; value: number }>;
}
export type EquipmentOrigin = "imported" | "app";
export interface EquipmentRow {
instance: string;
/**
* Resolved through `ownerNames` at read time and null when unknown. The
* character's name is **never** stored on the row: the owner group is exact,
* the name attached to it is a guess until the player makes it.
*/
ownerGroup: string | null;
characterId: string | null;
/** Board cells this item covers, or null when the capture did not say. */
cells: number[] | null;
/** `imported` means the game says so; `app` means you have not done it yet. */
origin: EquipmentOrigin;
/** Shared by every row written in one equip action, so undo is one entry. */
batchId: string;
at: number;
}
export interface CharacterRow {
characterId: string;
/** Refreshed by import. */
level: number | null;
breakthroughs: number | null;
/** Preserved across imports. */
arcId: string | null;
arcRefinement: number;
effectToggles: Record<string, boolean | number>;
buildVariant: string | null;
customTargets: Array<{ stat: string; target: number; weight: number }> | null;
useCustom: boolean;
/**
* What the game actually shows, read off-team and typed in by the player.
*
* The stat model has measured gaps, so a permanent predicted-vs-actual panel
* is how drift gets caught by the player instead of being trusted silently.
*/
measuredSheet: Record<string, number> | null;
}
export interface OwnerNameRow {
ownerGroup: string;
characterId: string;
at: number;
}
export interface UndoRow {
id?: number;
batchId: string;
before: EquipmentRow[];
after: EquipmentRow[];
at: number;
}
export const UNDO_LIMIT = 50;
export interface MetaRow {
key: string;
value: unknown;
}
export interface Database {
items: ItemRow[];
equipment: EquipmentRow[];
characters: CharacterRow[];
ownerNames: OwnerNameRow[];
meta: MetaRow[];
}
export function emptyCharacter(characterId: string): CharacterRow {
return {
characterId,
level: null,
breakthroughs: null,
arcId: null,
arcRefinement: 0,
effectToggles: {},
buildVariant: null,
customTargets: null,
useCustom: false,
measuredSheet: null,
};
}

277
src/db/store.ts Normal file
View file

@ -0,0 +1,277 @@
/**
* The in-memory mirror and the operations that change it.
*
* IndexedDB is the durable log; this is the query engine. Everything is loaded
* at boot - well under 5 MB - and filtered and sorted in JS, because substat
* filters are not indexable in IndexedDB anyway.
*/
import { mergeCharacters, type ImportResult } from "./import.ts";
import {
UNDO_LIMIT,
type Database,
type EquipmentRow,
type ItemRow,
type OwnerNameRow,
type UndoRow,
} from "./schema.ts";
import type { PersistenceAdapter, StoredState } from "./adapters/types.ts";
/** How many cells a complete board covers. */
export const BOARD_CELLS = 20;
export interface EquipOptions {
/** Cells each instance covers, when a packing decided them. */
cells?: Record<string, number[]>;
now?: () => number;
newBatchId?: () => string;
}
export interface EquipOutcome {
state: StoredState;
/**
* Characters left with a hole because this equip took an item they were
* wearing. Their build is `incomplete` - covered cells no longer total 20 -
* and the confirmation dialog has to name them and the piece before it runs.
*/
displaced: Array<{ characterId: string; instances: string[] }>;
}
/** The character wearing a row: stored directly, or resolved through the group. */
export function resolveCharacter(
row: EquipmentRow,
ownerNames: readonly OwnerNameRow[],
): string | null {
if (row.characterId) return row.characterId;
if (!row.ownerGroup) return null;
return ownerNames.find((name) => name.ownerGroup === row.ownerGroup)?.characterId ?? null;
}
export function equipmentOf(
db: Database,
characterId: string,
): EquipmentRow[] {
return db.equipment.filter((row) => resolveCharacter(row, db.ownerNames) === characterId);
}
/** Covered cells, when the rows carry them. A complete build totals 20. */
export function coveredCells(rows: readonly EquipmentRow[]): number {
let total = 0;
for (const row of rows) total += row.cells?.length ?? 0;
return total;
}
export function isComplete(rows: readonly EquipmentRow[]): boolean {
return coveredCells(rows) === BOARD_CELLS;
}
/**
* Rows the player has not actually applied in game yet.
*
* `origin` does all the work: after the next import these vanish on their own,
* because either you did it or you didn't. There is no reconciliation logic and
* there does not need to be.
*/
export function todoInGame(db: Database): Map<string, EquipmentRow[]> {
const out = new Map<string, EquipmentRow[]>();
for (const row of db.equipment) {
if (row.origin !== "app") continue;
const character = resolveCharacter(row, db.ownerNames) ?? "";
const bucket = out.get(character);
if (bucket) bucket.push(row);
else out.set(character, [row]);
}
return out;
}
/**
* Correct one item by hand.
*
* The decode is good but not infallible, and a player who spots a wrong value
* needs to fix it rather than work around it. The edit is stored like any other
* row, so the **next import overwrites it** - which is right: the import is the
* game speaking, and a correction that outlived the thing it corrected would be
* worse than losing it.
*/
export function editItem(
state: StoredState,
instance: string,
patch: Partial<Omit<ItemRow, "instance">>,
): StoredState {
const items = state.db.items.map((row) =>
row.instance === instance ? { ...row, ...patch, instance } : row,
);
return { ...state, db: { ...state.db, items } };
}
/**
* Instances worn by anyone other than these characters.
*
* This is R2's toggle turned **off**: no item may be taken from another
* character. With the toggle on the solve sees the whole pool, which is why the
* team solve has to name whoever is left with a hole.
*
* An item with no resolvable owner is *not* excluded: the capture says it is
* worn, but until the player names that owner group it belongs to nobody the app
* can reason about, and locking it away would hide most of the pool.
*/
export function heldByOthers(
db: Database,
characterIds: readonly string[],
): string[] {
const mine = new Set(characterIds);
const out: string[] = [];
for (const row of db.equipment) {
const owner = resolveCharacter(row, db.ownerNames);
if (owner !== null && !mine.has(owner)) out.push(row.instance);
}
return out;
}
/**
* Apply an import.
*
* Items and equipment are replaced outright; the pre-import snapshot is taken
* first; character configuration is merged rather than overwritten; owner names
* are left completely alone; undo is cleared, because its rows refer to items
* that may no longer exist.
*/
export function applyImport(
state: StoredState,
result: ImportResult,
now = Date.now,
): StoredState {
if (!result.ok || !result.rows) throw new Error("refusing to apply a failed import");
const { items, equipment, characters, userUid, serverId, exporterVersion } = result.rows;
const meta = state.db.meta.filter(
(row) => !["lastImport", "userUid", "serverId", "exporterVersion"].includes(row.key),
);
meta.push(
{ key: "lastImport", value: now() },
{ key: "userUid", value: userUid },
{ key: "serverId", value: serverId },
{ key: "exporterVersion", value: exporterVersion },
);
return {
snapshot: structuredClone(state.db),
undo: [],
db: {
items,
equipment,
characters: mergeCharacters(state.db.characters, characters),
ownerNames: state.db.ownerNames,
meta,
},
};
}
/**
* Equip a set of items on one character, as one atomic change.
*
* Both sides of the move go into a **single** undo entry: the character's old
* rows and any rows taken from someone else. One undo then restores both, which
* is the only behaviour that is not surprising.
*/
export function equip(
state: StoredState,
characterId: string,
instances: readonly string[],
options: EquipOptions = {},
): EquipOutcome {
const now = options.now ?? Date.now;
const at = now();
const batchId = options.newBatchId?.() ?? `equip-${at}-${characterId}`;
const wanted = new Set(instances);
const before: EquipmentRow[] = [];
const displacedBy = new Map<string, string[]>();
const kept = state.db.equipment.filter((row) => {
const owner = resolveCharacter(row, state.db.ownerNames);
if (owner === characterId) {
before.push(row);
return false;
}
if (wanted.has(row.instance)) {
before.push(row);
if (owner) {
const bucket = displacedBy.get(owner);
if (bucket) bucket.push(row.instance);
else displacedBy.set(owner, [row.instance]);
}
return false;
}
return true;
});
const after: EquipmentRow[] = instances.map((instance) => ({
instance,
ownerGroup: null,
characterId,
cells: options.cells?.[instance] ?? null,
origin: "app",
batchId,
at,
}));
const undo: UndoRow[] = [...state.undo, { batchId, before, after, at }].slice(-UNDO_LIMIT);
return {
state: { ...state, db: { ...state.db, equipment: [...kept, ...after] }, undo },
displaced: [...displacedBy].map(([id, list]) => ({ characterId: id, instances: list })),
};
}
/** Reverse the most recent equip, both sides of it. */
export function undoLast(state: StoredState): StoredState {
const entry = state.undo[state.undo.length - 1];
if (!entry) return state;
const removed = new Set(entry.after.map((row) => row.instance));
const equipment = state.db.equipment.filter((row) => !removed.has(row.instance));
const restored = entry.before.filter(
(row) => !equipment.some((existing) => existing.instance === row.instance),
);
return {
...state,
db: { ...state.db, equipment: [...equipment, ...restored] },
undo: state.undo.slice(0, -1),
};
}
/**
* Name an owner group.
*
* Keyed by the group, which is stable across exports, so this is answered once
* ever rather than once per import - and the name is never written onto an
* equipment row.
*/
export function nameOwnerGroup(
state: StoredState,
ownerGroup: string,
characterId: string,
now = Date.now,
): StoredState {
const ownerNames = state.db.ownerNames.filter((row) => row.ownerGroup !== ownerGroup);
ownerNames.push({ ownerGroup, characterId, at: now() });
return { ...state, db: { ...state.db, ownerNames } };
}
/** Groups the capture found that nobody has named yet. */
export function unnamedGroups(db: Database): string[] {
const named = new Set(db.ownerNames.map((row) => row.ownerGroup));
const groups = new Set<string>();
for (const row of db.equipment) {
if (row.ownerGroup && !named.has(row.ownerGroup)) groups.add(row.ownerGroup);
}
return [...groups].sort();
}
export async function persist(
adapter: PersistenceAdapter,
state: StoredState,
scope: "all" | "equipment",
): Promise<void> {
if (scope === "all") await adapter.replaceAll(state);
else await adapter.writeEquipment(state.db.equipment, state.undo);
}

153
src/domain/arcs.ts Normal file
View file

@ -0,0 +1,153 @@
/**
* What an Arc contributes.
*
* Two separate things, and they behave differently:
*
* 1. Its **own stat line** (`stats`), indexed by the Arc's level 1..80. Every
* Arc carries flat `AtkBase` plus one secondary stat.
* 2. Its **effect placeholders** (`values`), indexed by refinement 1..5. These
* are only usable when `data/arc_effects.json` classifies them, because the
* game data gives 104 of the 144 placeholders an empty `id_stats` and no way
* to tell a stat from a cooldown.
*
* The classification decides the control, not a guess about the text:
* `always` is unconditional, `toggle` is a checkbox, `stacks` is a 0..N count,
* and `duration` and `unmodellable` are never scored at all - they are reported
* so the UI can say what it is leaving out rather than silently dropping it.
*/
import type { Arc, ArcEffect, ArcEffectMode, GameData } from "../state/gamedata.ts";
import { emptyVector, slotOf } from "./statvec.ts";
export const MAX_ARC_LEVEL = 80;
export const MAX_REFINEMENT = 5;
export interface ArcConfig {
arcId: string;
/** 1..80. */
level: number;
/** 1..5. */
refinement: number;
/**
* Per placeholder index: `true`/`false` for a toggle, a count for stacks.
* Missing means the effect's own default.
*/
toggles?: Record<number, boolean | number>;
}
export interface ArcContribution {
vector: Float32Array;
/** Effects deliberately not scored, so the UI can say so. */
omitted: Array<{ placeholder: number; mode: ArcEffectMode; why: string }>;
/** Stats the model has no slot for. Should stay empty; a warning if not. */
unknownStats: string[];
}
interface RawStatCurve {
id_stats: string;
bIsPercent: boolean;
values: number[];
}
interface RawValue {
id_value: number;
id_stats: string;
bIsPercent: boolean;
values: Array<string | number>;
}
/** `"12%"` is 0.12; a bare number is itself. */
export function parseArcValue(raw: string | number, percent: boolean): number {
if (typeof raw === "number") return percent ? raw / 100 : raw;
const trimmed = raw.trim();
const numeric = Number.parseFloat(trimmed.replace("%", ""));
if (!Number.isFinite(numeric)) return 0;
return trimmed.endsWith("%") || percent ? numeric / 100 : numeric;
}
export function arcContribution(
arc: Arc,
effects: GameData["arcEffects"][string] | undefined,
config: ArcConfig,
): ArcContribution {
const vector = emptyVector();
const omitted: ArcContribution["omitted"] = [];
const unknownStats: string[] = [];
const level = Math.min(Math.max(Math.round(config.level), 1), MAX_ARC_LEVEL);
const refinement = Math.min(Math.max(Math.round(config.refinement), 1), MAX_REFINEMENT);
const add = (stat: string, value: number): void => {
const slot = slotOf(stat);
if (slot < 0) {
if (stat && !unknownStats.includes(stat)) unknownStats.push(stat);
return;
}
vector[slot] = vector[slot]! + value;
};
for (const curve of (arc.stats ?? []) as RawStatCurve[]) {
const raw = curve.values?.[level - 1];
if (raw === undefined) continue;
add(curve.id_stats, curve.bIsPercent ? raw / 100 : raw);
}
const byPlaceholder = new Map<number, ArcEffect>(
(effects?.effects ?? []).map((effect) => [effect.placeholder, effect]),
);
for (const entry of (arc.values ?? []) as RawValue[]) {
const effect = byPlaceholder.get(entry.id_value);
const mode: ArcEffectMode = effect?.mode ?? "unmodellable";
const raw = entry.values?.[refinement - 1];
if (mode === "duration" || mode === "unmodellable" || !entry.id_stats || raw === undefined) {
omitted.push({
placeholder: entry.id_value,
mode,
why: effect?.why ?? "no stat id in the game data",
});
continue;
}
const value = parseArcValue(raw, entry.bIsPercent);
const setting = config.toggles?.[entry.id_value];
if (mode === "always") {
add(entry.id_stats, value);
continue;
}
if (mode === "toggle") {
const on = typeof setting === "boolean" ? setting : (effect?.default ?? false);
if (on) add(entry.id_stats, value);
else omitted.push({ placeholder: entry.id_value, mode, why: "toggled off" });
continue;
}
// stacks
const count = typeof setting === "number" ? Math.max(0, Math.round(setting)) : 0;
if (count > 0) add(entry.id_stats, value * count);
else omitted.push({ placeholder: entry.id_value, mode, why: "no stacks assumed" });
}
return { vector, omitted, unknownStats };
}
/** The controls a UI needs for one Arc, in placeholder order. */
export function arcControls(
arc: Arc,
effects: GameData["arcEffects"][string] | undefined,
): Array<{ placeholder: number; mode: ArcEffectMode; stat: string; why: string }> {
const byPlaceholder = new Map<number, ArcEffect>(
(effects?.effects ?? []).map((effect) => [effect.placeholder, effect]),
);
return ((arc.values ?? []) as RawValue[])
.map((entry) => {
const effect = byPlaceholder.get(entry.id_value);
return {
placeholder: entry.id_value,
mode: effect?.mode ?? ("unmodellable" as ArcEffectMode),
stat: entry.id_stats,
why: effect?.why ?? "",
};
})
.filter((control) => control.mode === "toggle" || control.mode === "stacks");
}

132
src/domain/board.ts Normal file
View file

@ -0,0 +1,132 @@
/**
* Board geometry and packing.
*
* A board is the character's console grid: everness ships it as a 7x7 matrix
* with -1 blocked and 0 free. Every character's grid has 20 free cells.
*
* Positions never affect score - only the shape multiset does, via set tier and
* console trait - so packing is decided once, offline, and the runtime only
* ever looks up one canonical placement per multiset for rendering.
*/
import { SHAPES, SHAPE_IDS, sizeOf, type Cell, type ShapeId } from "./shapes.ts";
export type BoardCells = readonly number[];
export interface Board {
/** Free cells as row * 7 + col, ascending. */
cells: BoardCells;
rows: number;
cols: number;
}
export const GRID = 7;
export function boardFromSlots(slots: readonly (readonly number[])[]): Board {
const cells: number[] = [];
for (let row = 0; row < slots.length; row += 1) {
const line = slots[row]!;
for (let col = 0; col < line.length; col += 1) {
if (line[col] === 0) cells.push(row * GRID + col);
}
}
return { cells, rows: slots.length, cols: slots[0]?.length ?? GRID };
}
/** A stable identity for a board, so identical grids share one tiling table. */
export function boardKey(board: Board): string {
return board.cells.join(",");
}
/** Every translation of a shape that lands entirely on the board. */
export function placements(shape: ShapeId, board: Board): number[][] {
const free = new Set(board.cells);
const offsets = SHAPES[shape] as readonly Cell[];
const out: number[][] = [];
for (let dr = 0; dr < board.rows; dr += 1) {
for (let dc = 0; dc < board.cols; dc += 1) {
const placed: number[] = [];
let ok = true;
for (const [r, c] of offsets) {
const row = r + dr;
const col = c + dc;
if (col >= GRID) { ok = false; break; }
const index = row * GRID + col;
if (!free.has(index)) { ok = false; break; }
placed.push(index);
}
if (ok) out.push(placed.sort((a, b) => a - b));
}
}
return out;
}
export interface Placement {
shape: ShapeId;
cells: number[];
}
/**
* Exact-cover the board with the given shapes, or null.
*
* Always fills the lowest free cell next, so the search never explores two
* orderings of the same placement set.
*/
export function tile(board: Board, multiset: readonly ShapeId[]): Placement[] | null {
const ordered = [...multiset].sort();
const options = new Map<ShapeId, number[][]>();
for (const shape of new Set(ordered)) options.set(shape, placements(shape, board));
const free = new Set(board.cells);
const order = [...board.cells];
const placed: Placement[] = [];
const solve = (remaining: readonly ShapeId[]): boolean => {
if (remaining.length === 0) return free.size === 0;
const target = order.find((cell) => free.has(cell));
if (target === undefined) return false;
for (let i = 0; i < remaining.length; i += 1) {
const shape = remaining[i]!;
// Identical shapes are interchangeable; trying the second is wasted work.
if (i > 0 && shape === remaining[i - 1]) continue;
const rest = remaining.slice(0, i).concat(remaining.slice(i + 1));
for (const spot of options.get(shape)!) {
if (!spot.includes(target)) continue;
let fits = true;
for (const cell of spot) {
if (!free.has(cell)) { fits = false; break; }
}
if (!fits) continue;
for (const cell of spot) free.delete(cell);
placed.push({ shape, cells: spot });
if (solve(rest)) return true;
placed.pop();
for (const cell of spot) free.add(cell);
}
}
return false;
};
return solve(ordered) ? placed : null;
}
/**
* Shape multisets whose cells total exactly `freeCells`.
*
* Sizes are 2, 3 and 4, so a multiset covering 8 cells holds 2 to 4 pieces -
* which is why a full build is 6, 7 or 8 modules and never always 7.
*/
export function fillers(freeCells: number): ShapeId[][] {
const found: ShapeId[][] = [];
const walk = (start: number, left: number, current: ShapeId[]): void => {
if (left === 0) { found.push([...current]); return; }
for (let i = start; i < SHAPE_IDS.length; i += 1) {
const shape = SHAPE_IDS[i]!;
const size = sizeOf(shape);
if (size > left) continue;
current.push(shape);
walk(i, left - size, current);
current.pop();
}
};
walk(0, freeCells, []);
return found;
}

65
src/domain/cartridges.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Cartridge sets: the four module shapes each one needs to switch its bonuses on.
*
* A set needs four *specific distinct* shapes - not four of a type. Every set's
* four shapes total exactly 12 cells, so on a 20-cell board the requirement
* fixes 12 cells and leaves 8 free. That is the optimizer's search space.
*
* Bonus tiers count how many *distinct* required shapes are on the board, in any
* order and any position. Duplicates do not count twice.
*/
import type { ShapeId } from "./shapes.ts";
export type SetName = keyof typeof REQUIRED_PIECES;
export const REQUIRED_PIECES = {
"Crimson: Twin Butterflies": ["cell2_style2", "cell3_style1", "cell3_style6", "cell4_style5"],
"Devil's Blood: Curse": ["cell2_style2", "cell3_style2", "cell3_style4", "cell4_style6"],
Diabolos: ["cell2_style2", "cell3_style4", "cell3_style6", "cell4_style1"],
"Fireflies and the Forest": ["cell2_style1", "cell3_style2", "cell3_style3", "cell4_style6"],
"Kingdom's Guard": ["cell3_style1", "cell3_style2", "cell3_style5", "cell3_style6"],
"Lost Radiance": ["cell2_style1", "cell3_style3", "cell3_style5", "cell4_style2"],
"Quiet Manor": ["cell2_style1", "cell2_style2", "cell4_style2", "cell4_style6"],
"Shadow Creed": ["cell2_style1", "cell2_style2", "cell4_style1", "cell4_style6"],
"Speedy Hedgehog": ["cell3_style3", "cell3_style4", "cell3_style5", "cell3_style6"],
"Street Boxer": ["cell2_style1", "cell3_style1", "cell3_style5", "cell4_style5"],
"Thea's Night Tavern": ["cell3_style1", "cell3_style2", "cell3_style3", "cell3_style4"],
"Tiny Big Adventure": ["cell2_style1", "cell2_style2", "cell4_style1", "cell4_style5"],
} as const satisfies Record<string, readonly [ShapeId, ShapeId, ShapeId, ShapeId]>;
export const SET_NAMES = Object.keys(REQUIRED_PIECES) as SetName[];
/**
* Set ids as the packets spell them, confirmed against everness's cartridge
* boxes (`Testeqbox_<id>`) - the only source that names the three sets this
* account does not own. Those three are Psyche, Shield and Heal; an earlier
* guess of Blood, Night and Kingdom was wrong on all three.
*/
export const SET_IDS: Record<string, SetName> = {
Attack_orange: "Shadow Creed",
Chaos_orange: "Diabolos",
Cosmos_orange: "Lost Radiance",
GetEfficiency_orange: "Speedy Hedgehog",
Incantation_orange: "Crimson: Twin Butterflies",
Lakshana_orange: "Street Boxer",
Nature_orange: "Fireflies and the Forest",
Psychically_orange: "Quiet Manor",
Mag_orange: "Tiny Big Adventure",
// Not owned, so never yet seen in a capture.
Psyche_orange: "Devil's Blood: Curse",
Heal_orange: "Thea's Night Tavern",
Shield_orange: "Kingdom's Guard",
};
export type Tier = 2 | 4;
/** Which bonus tiers a board unlocks: [], [2] or [2, 4]. */
export function activeTiers(set: SetName, shapesOnBoard: Iterable<ShapeId>): Tier[] {
const required = new Set<string>(REQUIRED_PIECES[set]);
const present = new Set<string>();
for (const shape of shapesOnBoard) {
if (required.has(shape)) present.add(shape);
}
if (present.size >= 4) return [2, 4];
return present.size >= 2 ? [2] : [];
}

85
src/domain/guides.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* Published build guidance: targets and a ranked substat priority per character.
*
* The **weights the solver runs with come from the ranking**, not from a number
* somebody typed. A rank is what a guide actually publishes; inventing weights
* from it in one place keeps every character on the same scale.
*
* `data-src/guides.json` is committed and human-reviewed. Nothing is scraped at
* runtime: CORS forbids it, and a site redesign should break a script somebody
* runs deliberately rather than the app in a player's hands.
*/
import { isKnownStat, type StatId } from "./statvec.ts";
import type { StatTarget } from "./scoring.ts";
export interface GuideVariant {
/** "Main DPS", "SubDPS", "Break", "DoT" - a character can have several. */
name: string;
/** Recommended endgame stats: a floor to reach, not a ceiling. */
targets: Array<{ stat: string; target: number }>;
/** Substat priority, best first. */
priority: string[];
}
export interface CharacterGuide {
/** The `GA_<key>_*` ability key, so it joins to a capture directly. */
key: string;
source: string;
updated: string;
variants: GuideVariant[];
}
export interface GuideTable {
format: string;
format_version: number;
characters: CharacterGuide[];
}
/**
* Rank to weight.
*
* Linear from `top` down to 1: rank 1 is worth `top`, and the last ranked stat
* is still worth something. A geometric curve would make anything past third
* place worthless, which is not what "priority" means on a guide - the lower
* ranks are where a shortfall is *supposed* to land, not stats to ignore.
*/
export const TOP_WEIGHT = 5;
export function weightForRank(rank: number, total: number): number {
if (total <= 1) return TOP_WEIGHT;
const step = (TOP_WEIGHT - 1) / (total - 1);
return TOP_WEIGHT - step * rank;
}
/** The scoring targets a variant implies. */
export function targetsFromGuide(variant: GuideVariant): StatTarget[] {
const ranked = variant.priority.filter(isKnownStat);
const rankOf = new Map<string, number>(ranked.map((stat, index) => [stat, index]));
const out: StatTarget[] = [];
for (const entry of variant.targets) {
if (!isKnownStat(entry.stat)) continue;
const rank = rankOf.get(entry.stat);
out.push({
stat: entry.stat as StatId,
target: entry.target,
// A stat with a target but no place in the ranking still matters; it just
// sits at the bottom rather than being dropped.
weight: rank === undefined ? 1 : weightForRank(rank, ranked.length),
});
}
return out;
}
export function guideFor(table: GuideTable, key: string): CharacterGuide | null {
return table.characters.find((entry) => entry.key === key) ?? null;
}
export function variantFor(
guide: CharacterGuide | null,
name: string | null,
): GuideVariant | null {
if (!guide || guide.variants.length === 0) return null;
if (name) return guide.variants.find((variant) => variant.name === name) ?? guide.variants[0]!;
return guide.variants[0]!;
}

136
src/domain/items.ts Normal file
View file

@ -0,0 +1,136 @@
/**
* The owned pool, as the solver wants it.
*
* An export gives items as records with named stats. The solver wants one flat
* `Float32Array` and integer indices into it, so this is the one place the two
* representations meet. Nothing downstream of here handles a stat by name.
*/
import { SET_IDS, type SetName } from "./cartridges.ts";
import type { ShapeId } from "./shapes.ts";
import { SHAPES } from "./shapes.ts";
import { emptyPool, poolSlice, vectorFrom, type StatPair } from "./statvec.ts";
/** One item as an export spells it. */
export interface ExportedItem {
instance: string;
kind: "module" | "cartridge";
item_id: string;
// Explicitly `| undefined`: `exactOptionalPropertyTypes` otherwise refuses a
// value built by spreading a row whose field may be absent.
shape?: string | undefined;
module_type?: string | undefined;
set?: string | undefined;
level: number;
rarity: string;
main_stats: StatPair[];
substats: StatPair[];
owner_group: string | null;
}
export interface OwnedItem {
instance: string;
kind: "module" | "cartridge";
itemId: string;
/** Modules only. */
shape: ShapeId | null;
/** 2, 3 or 4. Modules only. */
cells: number | null;
/** Cartridges only. */
set: SetName | null;
level: number;
rarity: string;
ownerGroup: string | null;
/** Index into the pool's flat vector array. */
index: number;
/**
* The cartridge's main stat, which enters scoring only as a tiebreak. Null
* for modules, whose mains are fixed by cell count and carry no choice.
*/
mainStat: string | null;
}
export interface ItemPool {
items: OwnedItem[];
/** `items.length * SLOT_COUNT` values, laid end to end. */
vectors: Float32Array;
modulesByShape: Map<ShapeId, number[]>;
cartridgesBySet: Map<SetName, number[]>;
/** Items an import says are already worn, by owner group. */
equippedByOwner: Map<string, number[]>;
/** Item ids the decoder emitted that this build has no shape for. */
unknownShapes: string[];
}
const isShapeId = (value: string): value is ShapeId => value in SHAPES;
/**
* Build the pool.
*
* Level is deliberately not a filter: substat values are identical at +0 and
* +20, so an unlevelled item is a valid recommendation and the UI shows its
* level rather than the solver hiding it.
*/
export function buildPool(exported: readonly ExportedItem[]): ItemPool {
const items: OwnedItem[] = [];
const vectors = emptyPool(exported.length);
const modulesByShape = new Map<ShapeId, number[]>();
const cartridgesBySet = new Map<SetName, number[]>();
const equippedByOwner = new Map<string, number[]>();
const unknownShapes: string[] = [];
exported.forEach((raw, index) => {
const shape = raw.shape && isShapeId(raw.shape) ? raw.shape : null;
if (raw.kind === "module" && shape === null) unknownShapes.push(raw.item_id);
const set =
raw.kind === "cartridge"
? (SET_IDS[raw.item_id] ?? (raw.set as SetName | undefined) ?? null)
: null;
vectorFrom(raw.main_stats, poolSlice(vectors, index));
vectorFrom(raw.substats, poolSlice(vectors, index));
const item: OwnedItem = {
instance: raw.instance,
kind: raw.kind,
itemId: raw.item_id,
shape,
cells: shape ? SHAPES[shape].length : null,
set,
level: raw.level,
rarity: raw.rarity,
ownerGroup: raw.owner_group,
index,
mainStat: raw.kind === "cartridge" ? (raw.main_stats[0]?.stat ?? null) : null,
};
items.push(item);
if (item.kind === "module" && shape) {
const bucket = modulesByShape.get(shape);
if (bucket) bucket.push(index);
else modulesByShape.set(shape, [index]);
}
if (item.kind === "cartridge" && set) {
const bucket = cartridgesBySet.get(set);
if (bucket) bucket.push(index);
else cartridgesBySet.set(set, [index]);
}
if (item.ownerGroup) {
const bucket = equippedByOwner.get(item.ownerGroup);
if (bucket) bucket.push(index);
else equippedByOwner.set(item.ownerGroup, [index]);
}
});
return { items, vectors, modulesByShape, cartridgesBySet, equippedByOwner, unknownShapes };
}
/** Indices of every module of a shape, minus anything excluded. */
export function availableModules(
pool: ItemPool,
shape: ShapeId,
excluded: ReadonlySet<number>,
): number[] {
const all = pool.modulesByShape.get(shape) ?? [];
return excluded.size === 0 ? all : all.filter((index) => !excluded.has(index));
}

76
src/domain/render.ts Normal file
View file

@ -0,0 +1,76 @@
/**
* Turning a packing into something drawable.
*
* The result view has to read as blocks, not as a grid of squares: borders are
* drawn only on the edges facing a *different* piece, so each polyomino looks
* like one object rather than three or four cells that happen to share a colour.
* That is the whole point of the visual - the player looks at a block, reads its
* stats, and finds the matching module in their own inventory list.
*/
import { GRID } from "./board.ts";
export interface RenderedCell {
cell: number;
row: number;
col: number;
/** Index of the piece covering this cell, or -1 for a free board cell. */
piece: number;
/** Draw a border on this side: the neighbour is a different piece or off-board. */
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
}
export interface BoardRender {
cells: RenderedCell[];
rows: number;
cols: number;
}
/**
* `cells` are the board's free cells ascending; `placement[i]` is the piece
* covering `cells[i]`, matching `Tiling`.
*/
export function renderBoard(
cells: readonly number[],
placement: readonly number[],
): BoardRender {
const pieceAt = new Map<number, number>();
cells.forEach((cell, index) => pieceAt.set(cell, placement[index] ?? -1));
const samePiece = (cell: number, deltaRow: number, deltaCol: number): boolean => {
const row = Math.floor(cell / GRID) + deltaRow;
const col = (cell % GRID) + deltaCol;
// A move off the grid is not a neighbour, so that side always gets a border.
if (row < 0 || row >= GRID || col < 0 || col >= GRID) return false;
const neighbour = pieceAt.get(row * GRID + col);
return neighbour !== undefined && neighbour === pieceAt.get(cell);
};
const rendered = cells.map((cell, index) => ({
cell,
row: Math.floor(cell / GRID),
col: cell % GRID,
piece: placement[index] ?? -1,
top: !samePiece(cell, -1, 0),
right: !samePiece(cell, 0, 1),
bottom: !samePiece(cell, 1, 0),
left: !samePiece(cell, 0, -1),
}));
return { cells: rendered, rows: GRID, cols: GRID };
}
/**
* Distinct hues per piece, evenly spaced round the wheel.
*
* A build is 6 to 8 pieces, so an even split keeps neighbours far apart in hue
* without needing a hand-tuned palette that would break as soon as a build had
* one more piece than the palette had entries.
*/
export function pieceColour(piece: number, total: number): string {
if (piece < 0) return "transparent";
const hue = Math.round((360 / Math.max(total, 1)) * piece);
return `hsl(${hue} 62% 55%)`;
}

220
src/domain/scoring.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* The objective.
*
* Targets are Prydwen's recommended endgame stats - a floor to reach, not a
* ceiling - so each stat's attainment saturates at `min(value / target, 1)` and
* the score is the weighted mean of those attainments. Weights come from the
* character's published substat ranking, so where the targets cannot all be met
* the shortfall lands on the lowest-ranked stats by construction.
*
* Saturation has a consequence worth stating plainly: **once every target is
* cleared, every build ties at 1.0** and the winner is decided entirely by the
* tie-breakers below. Both are therefore per-stat, configurable and rendered -
* never a hidden constant.
*
* There is no damage model here. Without rotation or ability-multiplier data,
* anything claiming to maximise DPS would be inventing numbers.
*/
import { SLOT_COUNT, STAT_SLOTS, slotOf, type StatId } from "./statvec.ts";
export interface StatTarget {
stat: StatId;
/** The floor to reach. A target of 0 means "no target": weight is ignored. */
target: number;
/** Relative importance, from the substat ranking. Any positive scale. */
weight: number;
/**
* Credit for exceeding the target, as a fraction of this stat's own weight.
* 0 disables it; the default is small on purpose, so overshoot breaks ties
* rather than competing with reaching a target at all.
*/
overshoot?: number;
}
export interface ScoringConfig {
targets: StatTarget[];
/**
* How far past a target overshoot keeps paying, as a multiple of the target.
* 1 means credit runs out at double the target.
*/
overshootCap?: number;
/**
* The cartridge main-stat ranking is only a tiebreak - substats dominate - so
* it enters as an explicit epsilon rather than as a weight. Without a term
* like this the rule is not implementable at all.
*/
mainStatEpsilon?: number;
/** Published main-stat preference, 1 best down to 0. */
mainStatRank?: Partial<Record<StatId, number>>;
}
export interface CompiledScoring {
targets: Float32Array;
weights: Float32Array;
overshoot: Float32Array;
overshootCap: number;
mainStatEpsilon: number;
mainStatRank: Float32Array;
/** Sum of weights, so the score lands in 0..1 before tie-breakers. */
weightSum: number;
}
export const DEFAULT_OVERSHOOT = 0.1;
export const DEFAULT_OVERSHOOT_CAP = 1;
export const DEFAULT_MAIN_STAT_EPSILON = 0.001;
export function compile(config: ScoringConfig): CompiledScoring {
const targets = new Float32Array(SLOT_COUNT);
const weights = new Float32Array(SLOT_COUNT);
const overshoot = new Float32Array(SLOT_COUNT);
const mainStatRank = new Float32Array(SLOT_COUNT);
let weightSum = 0;
for (const entry of config.targets) {
const slot = slotOf(entry.stat);
if (slot < 0) throw new Error(`no stat slot for ${entry.stat}`);
if (entry.target <= 0 || entry.weight <= 0) continue;
targets[slot] = entry.target;
weights[slot] = entry.weight;
overshoot[slot] = entry.overshoot ?? DEFAULT_OVERSHOOT;
weightSum += entry.weight;
}
for (const [stat, rank] of Object.entries(config.mainStatRank ?? {})) {
const slot = slotOf(stat);
if (slot >= 0 && rank !== undefined) mainStatRank[slot] = rank;
}
return {
targets,
weights,
overshoot,
overshootCap: config.overshootCap ?? DEFAULT_OVERSHOOT_CAP,
mainStatEpsilon: config.mainStatEpsilon ?? DEFAULT_MAIN_STAT_EPSILON,
mainStatRank,
weightSum,
};
}
/**
* The hot path: one pass over the vector, no allocation.
*
* `mainStat` is the cartridge's main stat slot, or -1. It contributes only
* epsilon - substats decide the build, the main-stat ranking breaks the tie.
*/
export function score(
vector: Float32Array,
compiled: CompiledScoring,
mainStat = -1,
): number {
if (compiled.weightSum === 0) return 0;
let total = 0;
for (let i = 0; i < SLOT_COUNT; i += 1) {
const weight = compiled.weights[i]!;
if (weight === 0) continue;
const ratio = vector[i]! / compiled.targets[i]!;
if (ratio >= 1) {
const excess = Math.min(ratio - 1, compiled.overshootCap);
total += weight * (1 + compiled.overshoot[i]! * excess);
} else {
total += weight * ratio;
}
}
let value = total / compiled.weightSum;
if (mainStat >= 0) value += compiled.mainStatEpsilon * compiled.mainStatRank[mainStat]!;
return value;
}
/**
* An admissible upper bound for branch and bound.
*
* Every stat's attainment is monotone nondecreasing and concave in its value, so
* assuming each remaining slot takes the largest amount still available per stat
* can only overestimate. If the search closes against this bound the answer is a
* proof of optimality - and when it does not, the UI has to say so.
*/
export function upperBound(
partial: Float32Array,
bestRemaining: Float32Array,
compiled: CompiledScoring,
): number {
if (compiled.weightSum === 0) return 0;
let total = 0;
for (let i = 0; i < SLOT_COUNT; i += 1) {
const weight = compiled.weights[i]!;
if (weight === 0) continue;
const ratio = (partial[i]! + bestRemaining[i]!) / compiled.targets[i]!;
if (ratio >= 1) {
const excess = Math.min(ratio - 1, compiled.overshootCap);
total += weight * (1 + compiled.overshoot[i]! * excess);
} else {
total += weight * ratio;
}
}
return total / compiled.weightSum + compiled.mainStatEpsilon;
}
export interface StatBreakdown {
stat: StatId;
value: number;
target: number;
weight: number;
/** min(value / target, 1) - what the bars render. */
attainment: number;
/** How far past the target, as a fraction of it. 0 when short. */
overshoot: number;
/** This stat's share of the total, tie-breakers excluded. */
contribution: number;
}
export interface ScoreReport {
total: number;
/** The score with every overshoot and epsilon term removed. */
base: number;
/** True when every weighted target is met. */
complete: boolean;
stats: StatBreakdown[];
}
/**
* The same arithmetic, itemised. The result view renders this rather than a bare
* number, because a saturating objective is only trustworthy if the player can
* see which stats are carrying it.
*/
export function explain(
vector: Float32Array,
compiled: CompiledScoring,
mainStat = -1,
): ScoreReport {
const stats: StatBreakdown[] = [];
let base = 0;
let complete = true;
for (let i = 0; i < SLOT_COUNT; i += 1) {
const weight = compiled.weights[i]!;
if (weight === 0) continue;
const target = compiled.targets[i]!;
const value = vector[i]!;
const ratio = value / target;
const attainment = Math.min(ratio, 1);
const overshoot = ratio > 1 ? Math.min(ratio - 1, compiled.overshootCap) : 0;
if (attainment < 1) complete = false;
base += weight * attainment;
stats.push({
stat: STAT_SLOTS[i]!,
value,
target,
weight,
attainment,
overshoot,
contribution: (weight * attainment) / compiled.weightSum,
});
}
stats.sort((a, b) => b.weight - a.weight || a.stat.localeCompare(b.stat));
return {
total: score(vector, compiled, mainStat),
base: compiled.weightSum === 0 ? 0 : base / compiled.weightSum,
complete,
stats,
};
}

66
src/domain/setbonus.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* Cartridge set bonuses.
*
* The bonus *values* are the one piece of the model no available source
* carries: the capture never sends them, and everness's `items.json` holds only
* the cartridge boxes, not their effects. They have to be read in game or
* scraped from a guide.
*
* Until then every tier is explicitly unknown and contributes **nothing**. The
* requirements are firm that the app never invents numbers, so a missing bonus
* shows as a gap in the UI rather than as a plausible-looking zero the player
* cannot tell apart from a real one.
*/
import { activeTiers, type SetName, type Tier } from "./cartridges.ts";
import type { ShapeId } from "./shapes.ts";
import { vectorFrom, type StatPair } from "./statvec.ts";
export interface TierBonus {
unknown: boolean;
stats: StatPair[];
}
export interface SetBonusTable {
format: string;
format_version: number;
sets: Record<string, Record<"2" | "4", TierBonus>>;
}
export interface SetBonusResult {
tiers: Tier[];
vector: Float32Array;
/** Tiers that are active but whose values nobody has measured yet. */
unknownTiers: Tier[];
}
/** What a board's set bonuses contribute, and what is missing from that answer. */
export function setBonus(
table: SetBonusTable,
set: SetName,
shapesOnBoard: readonly ShapeId[],
): SetBonusResult {
const tiers = activeTiers(set, shapesOnBoard);
const entry = table.sets[set];
const pairs: StatPair[] = [];
const unknownTiers: Tier[] = [];
for (const tier of tiers) {
const bonus = entry?.[String(tier) as "2" | "4"];
if (!bonus || bonus.unknown) {
unknownTiers.push(tier);
continue;
}
pairs.push(...bonus.stats);
}
return { tiers, vector: vectorFrom(pairs), unknownTiers };
}
/** Sets with at least one unmeasured tier, for the UI to flag up front. */
export function incompleteSets(table: SetBonusTable): SetName[] {
const out: SetName[] = [];
for (const [set, tiers] of Object.entries(table.sets)) {
if (tiers["2"].unknown || tiers["4"].unknown) out.push(set as SetName);
}
return out.sort();
}

96
src/domain/shapes.ts Normal file
View file

@ -0,0 +1,96 @@
/**
* Module polyomino geometry, keyed by the id the packets carry.
*
* This is the complete shape set: 12 shapes, every one the game currently makes
* obtainable. The gap at cell4_style3/style4 is dead id space, not missing
* inventory - Type IV has four shapes and no more.
*
* Orientation is part of the item, not a placement choice: the horizontal and
* vertical dominoes are separate ids, as are the horizontal and vertical
* I-pieces. Modules therefore cannot be rotated when placed, and packing is
* translation-only.
*
* Cells are [row, col] with the origin at the shape's top-left corner.
*/
export type Cell = readonly [row: number, col: number];
export type ShapeId = keyof typeof SHAPES;
export const SHAPES = {
cell2_style1: [[0, 0], [0, 1]],
cell2_style2: [[0, 0], [1, 0]],
cell3_style1: [[0, 0], [0, 1], [0, 2]],
cell3_style2: [[0, 0], [1, 0], [2, 0]],
// The four L-trominoes are the four rotations of the same piece.
cell3_style3: [[0, 0], [1, 0], [1, 1]],
cell3_style4: [[0, 0], [0, 1], [1, 0]],
cell3_style5: [[0, 0], [0, 1], [1, 1]],
cell3_style6: [[0, 1], [1, 0], [1, 1]],
cell4_style1: [[0, 0], [0, 1], [0, 2], [0, 3]],
cell4_style2: [[0, 0], [1, 0], [2, 0], [3, 0]],
// These two are mirror images - an S and a Z - not one piece in two
// orientations. Rotating style5 gives 10/11/01, which is not style6, so the
// available orientations are an arbitrary subset per piece and cannot be
// generated.
cell4_style5: [[0, 1], [0, 2], [1, 0], [1, 1]],
cell4_style6: [[0, 1], [1, 0], [1, 1], [2, 0]],
} as const satisfies Record<string, readonly Cell[]>;
/** The order the in-game icons and Prydwen's `module_N.webp` use. */
export const ICON_ORDER = [
"cell2_style1",
"cell2_style2",
"cell3_style1",
"cell3_style2",
"cell3_style3",
"cell3_style4",
"cell3_style5",
"cell3_style6",
"cell4_style1",
"cell4_style2",
"cell4_style5",
"cell4_style6",
] as const satisfies readonly ShapeId[];
export const SHAPE_IDS = ICON_ORDER;
export function cellsOf(shape: ShapeId): readonly Cell[] {
return SHAPES[shape];
}
export function sizeOf(shape: ShapeId): number {
return SHAPES[shape].length;
}
/** 2, 3 or 4 - the module type, straight off the id. */
export function moduleCells(shape: ShapeId): number {
return Number(shape.slice(4, 5));
}
export function extentOf(shape: ShapeId): { rows: number; cols: number } {
const cells = SHAPES[shape];
let rows = 0;
let cols = 0;
for (const [row, col] of cells) {
if (row + 1 > rows) rows = row + 1;
if (col + 1 > cols) cols = col + 1;
}
return { rows, cols };
}
/** Rows of '1' and '0', for eyeballing and for test failure messages. */
export function renderShape(shape: ShapeId): string[] {
const { rows, cols } = extentOf(shape);
const filled = new Set(SHAPES[shape].map(([r, c]) => r * cols + c));
const out: string[] = [];
for (let row = 0; row < rows; row += 1) {
let line = "";
for (let col = 0; col < cols; col += 1) {
line += filled.has(row * cols + col) ? "1" : "0";
}
out.push(line);
}
return out;
}

Some files were not shown because too many files have changed in this diff Show more