diff --git a/README.md b/README.md index faf7928..2fd16c0 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,76 @@ # nte-optimizer -> [!CAUTION] -> **This repository is vibecoded.** Its implementation was written with substantial AI assistance and may contain plausible-looking mistakes, unsafe assumptions, or unreviewed edge cases. Treat it with care: inspect the code, verify outputs, and run the tests before relying on it. +Gear optimizer for *Neverness to Everness*. Static site, no server, no account. -Gear optimizer for *Neverness to Everness*. Static site, no server, no account, and no runtime connection to the game. +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. -It imports a gear snapshot produced by `nte-exporter`, scores the available cartridges and modules, and draws recommended builds directly onto each character's console grid. - -## Features - -- Single-character optimization with Prydwen targets or custom targets and weights. -- Cartridge set and 2-piece/4-piece bonus selection. -- Arc stats, refinements, conditional-effect controls, and explicit notices for effects the model cannot score. -- Team optimization against one shared item pool without assigning an item twice. -- Console-grid layouts with module placement and enough item detail to find each piece in game. -- Local equipment tracking, Equip/Undo, and import refreshes from game truth. -- Predicted-versus-measured character sheet comparison. -- Hosted build plus a single-file offline build. - -All account data stays in the browser. Nothing is uploaded by the app. - -## Running locally +## Running it ```sh npm ci -npm run dev -``` - -Useful checks and builds: - -```sh +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 -npm run check:guides -npm run build # hosted build in dist/ -npm run build:single # standalone dist/nte.html ``` -The standalone build works over `file://`. Browser restrictions disable workers, IndexedDB, and bundled artwork there; the app falls back to inline solving and localStorage or memory. +`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. -## Model boundaries +## Layout -This is a stat-target optimizer, not a damage simulator. It has no rotation or ability-multiplier model. - -- Targets are floors. Published substat rankings determine their relative weights. -- Timed bonuses are reported but not scored without defensible uptime data. -- Shield strength, enemy resistance, ally-only buffs, and other unsupported quantities are marked unmodellable instead of mapped onto a different stat. -- Level-80 base-stat scaling remains unmeasured, so dependent sheet values are shown as unavailable. -- “Proven” means optimal for one packing and cartridge, not globally optimal across every internal beam-search choice. - -## Project layout - -| Path | Contents | +| path | what lives there | |---|---| -| `data-src/` | Source classifications, cartridge bonuses, and guide data. | -| `tools/` | Data validation, generation, benchmarking, and build scripts. | -| `src/domain/` | Pure stat, board, cartridge, Arc, and scoring logic. | -| `src/solver/` | Worker-safe single-character and team solvers. | -| `src/db/` | Import validation and local persistence. | -| `src/ui/` | React interface. | -| `tests/` | Domain, solver, persistence, and integration checks. | +| `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. | -`src/domain/` and `src/solver/` stay free of DOM, storage, and UI imports so the same logic runs in tests, inline, or in a Web Worker. +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. + +## Deploying + +`npm run deploy` builds both targets and publishes to +**https://nte.goober.moe**. + +The homelab has no `rsync`, so the tree goes over as a tar stream, and the +destination is wiped first: a stale content-hashed chunk left behind would be +served forever, since `/assets/*` is cached as immutable. Caddy serves +`/mnt/docker/appdata/caddy/sites/nte.goober.moe` as `/srv/nte.goober.moe` via a +read-only bind mount, which is why a deploy needs no container restart — but +adding a *new* site does, and recreating that container briefly drops every +other site behind Caddy. + +`https://nte.goober.moe/nte.html` is the single-file offline build, shipped +alongside the app. + +`dist/` is not in git. The deploy builds from source. diff --git a/data-src/guides.json b/data-src/guides.json index 22e5d68..7fcbbe1 100644 --- a/data-src/guides.json +++ b/data-src/guides.json @@ -1,1197 +1,13 @@ { "format": "nte-guides", "format_version": 1, - "_source": "https://www.prydwen.gg/neverness-to-everness/characters/ - the Build tab, read on 2026-08-21 through a real browser session at their stated Crawl-delay of 10s.", - "_priority_is_substats": "`priority` is Prydwen's Sub Stats ranking, which is what the field documents. Their separate Main Stats ranking is not merged in: combining two published rankings into one would be our construction, not theirs. A main-stat-only priority (usually the elemental DMG bonus) therefore keeps its published target but sits at weight 1.", - "_ties": "Prydwen writes ties as `A = B` and emphasis as `>>`. Both are flattened to a plain ordering that keeps the printed left-to-right order, because the schema forbids duplicate ranks.", - "_targets_omit_totals": "Prydwen's `HP:`, `ATK:` and `DEF:` endgame lines are character TOTALS. The vector the solver scores is gear + Arc only and carries no character base stats, so those three are not comparable and are left out rather than mapped to the flat gear slots, which would be a units error. Every other published line has a zero or negligible base component and is carried through as published. Note Crit DMG still includes the character's ~50% base, so that target is conservative rather than exact.", - "_ranges": "Where a range or a conditional was published (`50% ~ 80%+`, `64-74%+`, `100%+ before 80% from Arc buff`) the lower bound is used, since a target is a floor to reach.", - "_absent": "A character with no published build data is absent, not guessed: linko.", + "_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__* ability key, so it joins to a capture directly", - "sets": "Prydwen's ranked cartridge set recommendation. Recorded as published; the solver still enumerates every owned set itself.", - "variants": "one per published priority set", + "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", - "updated": "the date this entry was read from Prydwen, not their revision date" + "priority": "substat ranking, best first; weights are derived from position" }, - "characters": [ - { - "key": "Adler", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/adler", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Kingdom's Guard" - }, - { - "rank": 2, - "name": "Speedy Hedgehog" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.05 - }, - { - "stat": "CritDamageBase", - "target": 0.5 - }, - { - "stat": "MagBase", - "target": 50.0 - }, - { - "stat": "UnbalIntensityBase", - "target": 75.0 - } - ], - "priority": [ - "DefUp", - "DefAdd", - "UnbalIntensityBase", - "MagBase", - "DamageUpGeneralBase" - ] - } - ] - }, - { - "key": "Mitsuki", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/aurelia", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Devil's Blood: Curse" - }, - { - "rank": 2, - "name": "Lost Radiance" - }, - { - "rank": 3, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.6 - }, - { - "stat": "CritDamageBase", - "target": 0.9 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.15 - }, - { - "stat": "DamageUpPsycheBase", - "target": 0.3 - } - ], - "priority": [ - "CritDamageBase", - "CritBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd" - ] - } - ] - }, - { - "key": "Cang", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/baicang", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Crimson: Twin Butterflies" - }, - { - "rank": 2, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.75 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "UnbalIntensityBase", - "target": 25.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpIncantationBase", - "target": 0.475 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd", - "UnbalIntensityBase" - ] - } - ] - }, - { - "key": "Chaos071", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/chaos", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Street Boxer" - }, - { - "rank": 2, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.64 - }, - { - "stat": "CritDamageBase", - "target": 1.6 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.15 - }, - { - "stat": "DamageUpLakshanaBase", - "target": 0.675 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Chiichan073", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/chiz", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Lost Radiance" - }, - { - "rank": 2, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.4 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.2 - }, - { - "stat": "DamageUpCosmosBase", - "target": 0.55 - } - ], - "priority": [ - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Daffodill", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/daffodil", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Diabolos" - } - ], - "variants": [ - { - "name": "Awakening 0 (No Dupes)", - "targets": [ - { - "stat": "CritBase", - "target": 0.45 - }, - { - "stat": "CritDamageBase", - "target": 0.9 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpChaosBase", - "target": 0.5 - }, - { - "stat": "UnbalIntensityBase", - "target": 150.0 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd", - "UnbalIntensityBase" - ] - }, - { - "name": "Awakening 2+ (Using Awakening 3 and 5)", - "targets": [ - { - "stat": "CritBase", - "target": 0.3 - }, - { - "stat": "CritDamageBase", - "target": 0.6 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpChaosBase", - "target": 0.4 - }, - { - "stat": "UnbalIntensityBase", - "target": 350.0 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd", - "UnbalIntensityBase" - ] - } - ] - }, - { - "key": "Edgar", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/edgar", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Thea's Night Tavern" - }, - { - "rank": 2, - "name": "Speedy Hedgehog" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.05 - }, - { - "stat": "CritDamageBase", - "target": 0.5 - }, - { - "stat": "MagBase", - "target": 50.0 - }, - { - "stat": "UnbalIntensityBase", - "target": 50.0 - } - ], - "priority": [ - "HPMaxUp", - "HPMaxAdd", - "MagBase", - "UnbalIntensityBase" - ] - } - ] - }, - { - "key": "Fadia", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/fadia", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Tiny Big Adventure" - }, - { - "rank": 2, - "name": "Quiet Manor" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.05 - }, - { - "stat": "CritDamageBase", - "target": 0.5 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.2 - } - ], - "priority": [ - "HPMaxUp", - "HPMaxAdd", - "DamageUpGeneralBase", - "CritBase", - "CritDamageBase" - ] - } - ] - }, - { - "key": "Haniel", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/haniel", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Speedy Hedgehog" - }, - { - "rank": 2, - "name": "Fireflies and the Forest" - }, - { - "rank": 3, - "name": "Devil's Blood: Curse" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.5 - }, - { - "stat": "CritDamageBase", - "target": 0.9 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.15 - }, - { - "stat": "DamageUpPsycheBase", - "target": 0.2 - } - ], - "priority": [ - "CritDamageBase", - "DamageUpGeneralBase", - "CritBase", - "AtkUp", - "UnbalIntensityBase", - "AtkAdd" - ] - } - ] - }, - { - "key": "Hathor", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/hathor", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Street Boxer" - }, - { - "rank": 2, - "name": "Devil's Blood: Curse" - }, - { - "rank": 3, - "name": "Fireflies and the Forest" - }, - { - "rank": 4, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.3 - }, - { - "stat": "CritDamageBase", - "target": 1.2 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.15 - }, - { - "stat": "DamageUpLakshanaBase", - "target": 0.625 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Jin", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/hotori", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Lost Radiance" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.7 - }, - { - "stat": "CritDamageBase", - "target": 0.9 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpCosmosBase", - "target": 0.5 - } - ], - "priority": [ - "CritDamageBase", - "CritBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Oneiroi", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/iroi", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Speedy Hedgehog" - }, - { - "rank": 2, - "name": "Fireflies and the Forest" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.3 - }, - { - "stat": "CritDamageBase", - "target": 0.7 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "MagBase", - "target": 150.0 - } - ], - "priority": [ - "MagBase", - "CritDamageBase", - "CritBase", - "DamageUpGeneralBase", - "AtkUp" - ] - } - ] - }, - { - "key": "Kuhara", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/jiuyuan", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Fireflies and the Forest" - }, - { - "rank": 2, - "name": "Devil's Blood: Curse" - }, - { - "rank": 3, - "name": "Street Boxer" - }, - { - "rank": 4, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.8 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpNatureBase", - "target": 0.35 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd" - ] - } - ] - }, - { - "key": "Lacrimosa", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/lacrimosa", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Diabolos" - }, - { - "rank": 2, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.5 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpChaosBase", - "target": 0.5 - } - ], - "priority": [ - "CritDamageBase", - "CritBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Mint019", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/mint", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Fireflies and the Forest" - }, - { - "rank": 2, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.6 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpNatureBase", - "target": 0.475 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd" - ] - } - ] - }, - { - "key": "Nanally", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/nanally", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Fireflies and the Forest" - }, - { - "rank": 2, - "name": "Shadow Creed" - }, - { - "rank": 3, - "name": "Lost Radiance" - }, - { - "rank": 4, - "name": "Devil's Blood: Curse" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.85 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpNatureBase", - "target": 0.475 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "AtkUp", - "DamageUpGeneralBase", - "AtkAdd" - ] - } - ] - }, - { - "key": "Sagiri", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/sakiri", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Speedy Hedgehog" - }, - { - "rank": 2, - "name": "Crimson: Twin Butterflies" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.5 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "UnbalIntensityBase", - "target": 50.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpIncantationBase", - "target": 0.54 - } - ], - "priority": [ - "CritDamageBase", - "CritBase", - "AtkUp", - "AtkAdd", - "UnbalIntensityBase", - "MagBase" - ] - } - ] - }, - { - "key": "Shinku", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/shinku", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Lost Radiance" - }, - { - "rank": 2, - "name": "Street Boxer" - }, - { - "rank": 3, - "name": "Fireflies and the Forest" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.75 - }, - { - "stat": "CritDamageBase", - "target": 1.5 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.15 - }, - { - "stat": "DamageUpCosmosBase", - "target": 0.1 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Skia", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/skia", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Street Boxer" - }, - { - "rank": 2, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.3 - }, - { - "stat": "CritDamageBase", - "target": 1.35 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.15 - }, - { - "stat": "DamageUpLakshanaBase", - "target": 0.375 - } - ], - "priority": [ - "CritBase", - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Zankou", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/zankou", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Crimson: Twin Butterflies" - }, - { - "rank": 2, - "name": "Lost Radiance" - }, - { - "rank": 3, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Recommended", - "targets": [ - { - "stat": "CritBase", - "target": 0.6 - }, - { - "stat": "CritDamageBase", - "target": 2.0 - }, - { - "stat": "MagBase", - "target": 200.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpIncantationBase", - "target": 0.1 - } - ], - "priority": [ - "CritDamageBase", - "MagBase", - "CritBase", - "DamageUpGeneralBase", - "AtkUp", - "UnbalIntensityBase" - ] - }, - { - "name": "SubDPS", - "targets": [ - { - "stat": "CritBase", - "target": 0.6 - }, - { - "stat": "CritDamageBase", - "target": 2.0 - }, - { - "stat": "MagBase", - "target": 200.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "DamageUpIncantationBase", - "target": 0.1 - } - ], - "priority": [ - "CritDamageBase", - "MagBase", - "DamageUpGeneralBase", - "CritBase", - "AtkUp", - "UnbalIntensityBase" - ] - } - ] - }, - { - "key": "Female046", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/zero", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Speedy Hedgehog" - }, - { - "rank": 2, - "name": "Lost Radiance" - }, - { - "rank": 3, - "name": "Fireflies and the Forest" - }, - { - "rank": 4, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Day Off", - "targets": [ - { - "stat": "CritBase", - "target": 0.45 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "MagBase", - "target": 50.0 - } - ], - "priority": [ - "CritDamageBase", - "CritBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - }, - { - "name": "The Rain That Shook the World", - "targets": [ - { - "stat": "CritBase", - "target": 0.45 - }, - { - "stat": "CritDamageBase", - "target": 0.9 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "MagBase", - "target": 250.0 - } - ], - "priority": [ - "MagBase", - "CritBase", - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - }, - { - "key": "Female051", - "source": "https://www.prydwen.gg/neverness-to-everness/characters/zero", - "updated": "2026-08-21", - "sets": [ - { - "rank": 1, - "name": "Speedy Hedgehog" - }, - { - "rank": 2, - "name": "Lost Radiance" - }, - { - "rank": 3, - "name": "Fireflies and the Forest" - }, - { - "rank": 4, - "name": "Shadow Creed" - } - ], - "variants": [ - { - "name": "Day Off", - "targets": [ - { - "stat": "CritBase", - "target": 0.45 - }, - { - "stat": "CritDamageBase", - "target": 1.0 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "MagBase", - "target": 50.0 - } - ], - "priority": [ - "CritDamageBase", - "CritBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - }, - { - "name": "The Rain That Shook the World", - "targets": [ - { - "stat": "CritBase", - "target": 0.45 - }, - { - "stat": "CritDamageBase", - "target": 0.9 - }, - { - "stat": "DamageUpGeneralBase", - "target": 0.1 - }, - { - "stat": "MagBase", - "target": 250.0 - } - ], - "priority": [ - "MagBase", - "CritBase", - "CritDamageBase", - "DamageUpGeneralBase", - "AtkUp", - "AtkAdd" - ] - } - ] - } - ] + "characters": [] } diff --git a/data-src/set-bonuses.json b/data-src/set-bonuses.json index b2e5b3c..fa7d14c 100644 --- a/data-src/set-bonuses.json +++ b/data-src/set-bonuses.json @@ -1,55 +1,127 @@ { - "format": "nte-set-bonuses", - "format_version": 2, - "source": "Prydwen, read 2026-08-21; page updated 2026-04-26", - "sets": { - "Crimson: Twin Butterflies": { - "2": {"description": "Incantation DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpIncantationBase", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "ATK +6% per nearby team Incantation DMG trigger, up to 6 stacks; each lasts 10s.", "mode": "stacks", "max_stacks": 6, "stats": [{"stat": "AtkUp", "value": 0.06}], "why": "Wearer ATK stacks to 6 and remains active off-field."} - }, - "Devil's Blood: Curse": { - "2": {"description": "Psyche DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsycheBase", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "DMG +18%, increased to 36% against units affected by Nova or Stain.", "mode": "always", "stats": [{"stat": "DamageUpGeneralBase", "value": 0.18}], "why": "The unconditional 18% is modelled; the target-conditional increase is not assumed."} - }, - "Diabolos": { - "2": {"description": "Chaos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpChaosBase", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "Ignores 12% Chaos RES, increased to 24% for 20s after Nova or Scorch.", "mode": "unmodellable", "stats": [], "why": "Chaos RES is not DEF; mapping this to DefIgnore would model a different quantity."} - }, - "Fireflies and the Forest": { - "2": {"description": "Anima DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpNatureBase", "value": 0.1}], "why": "Anima is named Nature in the stat ids."}, - "4": {"description": "CRIT DMG +8% per nearby team Anima DMG trigger, up to 7 stacks; each lasts 10s.", "mode": "stacks", "max_stacks": 7, "stats": [{"stat": "CritDamageBase", "value": 0.08}], "why": "Wearer CRIT DMG stacks to 7 and remains active off-field."} - }, - "Kingdom's Guard": { - "2": {"description": "DEF +15%", "mode": "always", "stats": [{"stat": "DefUp", "value": 0.15}], "why": "Unconditional wearer stat."}, - "4": {"description": "Shields +20%", "mode": "unmodellable", "stats": [], "why": "Shield strength has no stat-vector slot."} - }, - "Lost Radiance": { - "2": {"description": "Cosmos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpCosmosBase", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "Ignores 25% DEF for 20s after Ultimate.", "mode": "duration", "stats": [{"stat": "DefIgnore", "value": 0.25}], "why": "Timed post-Ultimate stat; uptime needs rotation data, so it is reported but not scored."} - }, - "Quiet Manor": { - "2": {"description": "Mental DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsychicallyBase", "value": 0.1}], "why": "User-decided circumstantial mapping: DamageUpPsychicallyBase is the only non-element damage id, appears on 19 real items, and Fadia's guide pairs Mental DMG with Quiet Manor. Not sheet-confirmed; check in game."}, - "4": {"description": "Mental DMG +12% per Basic Attack, up to 3 stacks; each lasts 6s.", "mode": "stacks", "max_stacks": 3, "stats": [{"stat": "DamageUpPsychicallyBase", "value": 0.12}], "why": "Uses the same user-decided Mental DMG mapping as the 2-piece tier; stacks to 3."} - }, - "Shadow Creed": { - "2": {"description": "ATK +10%", "mode": "always", "stats": [{"stat": "AtkUp", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "ATK +25% for 20s after Skill.", "mode": "duration", "stats": [{"stat": "AtkUp", "value": 0.25}], "why": "Timed post-Skill stat; uptime needs rotation data, so it is reported but not scored."} - }, - "Speedy Hedgehog": { - "2": {"description": "Charge Efficiency +12%", "mode": "always", "stats": [{"stat": "ChargeGetEfficiencyBase", "value": 0.12}], "why": "Unconditional wearer stat."}, - "4": {"description": "All allies' ATK +15% for 20s after wearer uses Ultimate.", "mode": "unmodellable", "stats": [], "why": "Buff applies to allies, not the wearer being optimised."} - }, - "Street Boxer": { - "2": {"description": "Lakshana DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpLakshanaBase", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "CRIT Rate +14%.", "mode": "always", "stats": [{"stat": "CritBase", "value": 0.14}], "why": "Prydwen's reaction sentence repeats 14% from 14%; treated as a source typo, not a second value."} - }, - "Thea's Night Tavern": { - "2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "Healing Bonus +20%", "mode": "always", "stats": [{"stat": "HealUp", "value": 0.2}], "why": "Unconditional wearer stat."} - }, - "Tiny Big Adventure": { - "2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."}, - "4": {"description": "Max HP +4% when taking damage, up to 10 stacks; each lasts 10s; Ultimate grants 10.", "mode": "stacks", "max_stacks": 10, "stats": [{"stat": "HPMaxUp", "value": 0.04}], "why": "Wearer Max HP stacks to 10; targeting the 4-piece tier uses its stated maximum."} - } + "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": [] + } } + } } diff --git a/package.json b/package.json index a7b3aa0..424a3f9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -b --noEmit", - "check:guides": "node --experimental-strip-types tools/check-guides.ts" + "check:guides": "node --experimental-strip-types tools/check-guides.ts", + "deploy": "sh tools/deploy.sh" }, "dependencies": { "idb": "8.0.3", diff --git a/src/domain/guides.ts b/src/domain/guides.ts index 9decf29..0ab3f88 100644 --- a/src/domain/guides.ts +++ b/src/domain/guides.ts @@ -21,26 +21,11 @@ export interface GuideVariant { priority: string[]; } -/** A recommended cartridge set, in the order the guide ranks them. */ -export interface GuideSet { - rank: number; - /** The published name, which is also its key in `set-bonuses.json`. */ - name: string; -} - export interface CharacterGuide { /** The `GA__*` ability key, so it joins to a capture directly. */ key: string; source: string; updated: string; - /** - * The guide's ranked cartridge recommendation, recorded as published. - * - * Nothing in the solve reads this: the solver enumerates every set the player - * owns and is free to disagree, which is the point of running it. It is kept - * so the published recommendation can be shown beside the solved build. - */ - sets?: GuideSet[]; variants: GuideVariant[]; } diff --git a/src/domain/setbonus.ts b/src/domain/setbonus.ts index 7c050ee..061d139 100644 --- a/src/domain/setbonus.ts +++ b/src/domain/setbonus.ts @@ -1,18 +1,23 @@ -/** Cartridge set bonuses, classified with the same modes as Arc effects. */ +/** + * 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 StatId, type StatPair } from "./statvec.ts"; - -export type BonusMode = "always" | "toggle" | "stacks" | "duration" | "unmodellable"; +import { vectorFrom, type StatPair } from "./statvec.ts"; export interface TierBonus { - description: string; - mode: BonusMode; - stats: Array<{ stat: StatId; value: number }>; - why: string; - max_stacks?: number; - /** Kept for future source gaps; v2 classifies every current tier. */ - unknown?: boolean; + unknown: boolean; + stats: StatPair[]; } export interface SetBonusTable { @@ -21,17 +26,11 @@ export interface SetBonusTable { sets: Record>; } -export interface OmittedTier { - tier: Tier; - mode: "duration" | "unmodellable"; - why: string; -} - export interface SetBonusResult { tiers: Tier[]; vector: Float32Array; + /** Tiers that are active but whose values nobody has measured yet. */ unknownTiers: Tier[]; - omittedTiers: OmittedTier[]; } /** What a board's set bonuses contribute, and what is missing from that answer. */ @@ -39,41 +38,29 @@ export function setBonus( table: SetBonusTable, set: SetName, shapesOnBoard: readonly ShapeId[], - targetTier: Tier = 4, ): SetBonusResult { const tiers = activeTiers(set, shapesOnBoard); const entry = table.sets[set]; const pairs: StatPair[] = []; const unknownTiers: Tier[] = []; - const omittedTiers: OmittedTier[] = []; for (const tier of tiers) { - if (tier > targetTier) continue; const bonus = entry?.[String(tier) as "2" | "4"]; if (!bonus || bonus.unknown) { unknownTiers.push(tier); continue; } - if (bonus.mode === "duration" || bonus.mode === "unmodellable") { - omittedTiers.push({ tier, mode: bonus.mode, why: bonus.why }); - continue; - } - - // ponytail: selecting 2+4 means aiming for stated max stacks; add a stack control only if users need partial uptime. - const multiplier = bonus.mode === "stacks" ? bonus.max_stacks ?? 0 : 1; - pairs.push(...bonus.stats.map(({ stat, value }) => ({ stat, value: value * multiplier }))); + pairs.push(...bonus.stats); } - return { tiers, vector: vectorFrom(pairs), unknownTiers, omittedTiers }; + 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"] || !tiers["4"] || tiers["2"].unknown || tiers["4"].unknown) { - out.push(set as SetName); - } + if (tiers["2"].unknown || tiers["4"].unknown) out.push(set as SetName); } return out.sort(); } diff --git a/src/solver/job.ts b/src/solver/job.ts index 9ac6d78..61aa526 100644 --- a/src/solver/job.ts +++ b/src/solver/job.ts @@ -7,7 +7,7 @@ * host and the worker host run byte-identical code and cannot drift. */ import { boardFromSlots, boardKey } from "../domain/board.ts"; -import type { SetName, Tier } from "../domain/cartridges.ts"; +import type { SetName } from "../domain/cartridges.ts"; import { buildPool, type ExportedItem } from "../domain/items.ts"; import { compile, type ScoringConfig } from "../domain/scoring.ts"; import type { SetBonusTable } from "../domain/setbonus.ts"; @@ -29,8 +29,6 @@ export interface CharacterJob { scoring: ScoringConfig; /** Restrict to these sets. Omit for every set the account owns. */ sets?: SetName[]; - /** Highest set-bonus tier to score: 2, or both 2 and 4. */ - targetTier?: Tier; /** * Instances this character may not use - R2's "use equipped items" toggle * turned off, meaning items another character is wearing are off limits. @@ -131,7 +129,6 @@ export function* runJob(job: SolveJob): Generator base: baseVector(character), trait: character.trait, setBonuses: job.setBonuses, - targetTier: character.targetTier ?? 4, scoring: compile(character.scoring), excluded, beamWidth: options.beamWidth, diff --git a/src/solver/protocol.ts b/src/solver/protocol.ts index bb60094..9fa0455 100644 --- a/src/solver/protocol.ts +++ b/src/solver/protocol.ts @@ -33,10 +33,8 @@ export interface Build { * The UI must render the distinction rather than implying certainty. */ proven: boolean; - /** Selected active set tiers still lacking source data. */ + /** Active set tiers whose values nobody has measured yet. */ unknownTiers: Tier[]; - /** Selected tiers that were classified but cannot be scored honestly. */ - omittedTiers: Array<{ tier: Tier; mode: "duration" | "unmodellable"; why: string }>; } export interface SolveResult { diff --git a/src/solver/single.ts b/src/solver/single.ts index b99afa4..52c5199 100644 --- a/src/solver/single.ts +++ b/src/solver/single.ts @@ -29,7 +29,6 @@ * for free. */ import type { ConsoleTrait } from "../domain/stats.ts"; -import type { Tier } from "../domain/cartridges.ts"; import type { ItemPool } from "../domain/items.ts"; import type { SetBonusTable } from "../domain/setbonus.ts"; import type { Tiling } from "../domain/tilings.ts"; @@ -47,7 +46,6 @@ export interface SingleRequest { base: Float32Array; trait: ConsoleTrait | null; setBonuses: SetBonusTable; - targetTier?: Tier; scoring: CompiledScoring; /** Pool indices held by other characters and therefore off limits. */ excluded?: ReadonlySet; @@ -153,7 +151,6 @@ export function* solveSingle( base, trait, setBonuses, - targetTier = 4, scoring, excluded = new Set(), beamWidth = DEFAULT_BEAM_WIDTH, @@ -204,7 +201,7 @@ export function* solveSingle( fixed.set(base); const traitStat = traitContribution(trait, tiling.pieces); if (traitStat) vectorFrom([traitStat], fixed); - const bonus = setBonus(setBonuses, tiling.set, tiling.pieces, targetTier); + const bonus = setBonus(setBonuses, tiling.set, tiling.pieces); for (let i = 0; i < SLOT_COUNT; i += 1) fixed[i] = fixed[i]! + bonus.vector[i]!; // Hardest groups first: fewer choices near the root prunes the most. @@ -273,7 +270,6 @@ export function* solveSingle( vector, proven: false, unknownTiers: bonus.unknownTiers, - omittedTiers: bonus.omittedTiers, }; } if (bestForState === null) continue; diff --git a/src/ui/CharacterView.tsx b/src/ui/CharacterView.tsx index d21bb61..63178c6 100644 --- a/src/ui/CharacterView.tsx +++ b/src/ui/CharacterView.tsx @@ -15,7 +15,6 @@ import { useMemo, useRef, useState } from "react"; import { arcContribution, arcControls, MAX_ARC_LEVEL, MAX_REFINEMENT } from "../domain/arcs.ts"; import { boardFromSlots } from "../domain/board.ts"; -import { SET_NAMES, type SetName, type Tier } from "../domain/cartridges.ts"; import { compile, explain, type StatTarget } from "../domain/scoring.ts"; import { guideFor, targetsFromGuide, variantFor } from "../domain/guides.ts"; import { compareSheet, predictSheet } from "../domain/sheet.ts"; @@ -90,11 +89,6 @@ export function CharacterView({ // R2. Off by default: taking gear off another character is a real cost, so it // is opted into rather than assumed. const [useEquipped, setUseEquipped] = useState(false); - const [setName, setSetName] = useState(() => { - const recommended = guide?.sets?.[0]?.name; - return recommended && recommended in data.setBonuses.sets ? (recommended as SetName) : ""; - }); - const [targetTier, setTargetTier] = useState(2); const handle = useRef(null); const worn = equipmentOf(db, characterId); @@ -160,8 +154,6 @@ export function CharacterView({ trait, base: arcResult ? Array.from(arcResult.vector) : new Array(SLOT_COUNT).fill(0), scoring: { targets }, - ...(setName ? { sets: [setName] } : {}), - targetTier, excludedInstances: useEquipped ? [] : heldByOthers(db, [characterId]), }, ], @@ -326,33 +318,6 @@ export function CharacterView({ )} -
- - -
-