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:
commit
141bb2c751
157 changed files with 92605 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal 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
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
22.23.2
|
||||||
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 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, and no runtime connection to the game.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful checks and builds:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Model boundaries
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
`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.
|
||||||
51
data-src/arc_effects.json
Normal file
51
data-src/arc_effects.json
Normal 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
14708
data-src/arcs.json
Normal file
File diff suppressed because it is too large
Load diff
66951
data-src/espers.json
Normal file
66951
data-src/espers.json
Normal file
File diff suppressed because it is too large
Load diff
13
data-src/guides.json
Normal file
13
data-src/guides.json
Normal 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
127
data-src/set-bonuses.json
Normal 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
12
index.html
Normal 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
1322
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
37
package.json
Normal file
37
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/img/fork_Arachne_256-159158f2.webp
Normal file
1
public/img/fork_Arachne_256-159158f2.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_BitGame_256-0cb5217a.webp
Normal file
1
public/img/fork_BitGame_256-0cb5217a.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_BitterCake_256-2498fb35.webp
Normal file
1
public/img/fork_BitterCake_256-2498fb35.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_BlackBook_256-56849e90.webp
Normal file
1
public/img/fork_BlackBook_256-56849e90.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_BlastCandy_256-4bd61b87.webp
Normal file
1
public/img/fork_BlastCandy_256-4bd61b87.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_BoxingCandy_256-2d9a5897.webp
Normal file
1
public/img/fork_BoxingCandy_256-2d9a5897.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Butterfly_256-1395ba48.webp
Normal file
1
public/img/fork_Butterfly_256-1395ba48.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Castle_256-3ce261aa.webp
Normal file
1
public/img/fork_Castle_256-3ce261aa.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_DemonBlade_256-89200ec7.webp
Normal file
1
public/img/fork_DemonBlade_256-89200ec7.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Door_256-7a125283.webp
Normal file
1
public/img/fork_Door_256-7a125283.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_GoldRecord_256-9e37a9a5.webp
Normal file
1
public/img/fork_GoldRecord_256-9e37a9a5.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_GoldWool_256-929d1333.webp
Normal file
1
public/img/fork_GoldWool_256-929d1333.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_HugVine_256-aaa93ce6.webp
Normal file
1
public/img/fork_HugVine_256-aaa93ce6.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Kite_256-a8b52a91.webp
Normal file
1
public/img/fork_Kite_256-a8b52a91.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_KnightCandy_256-fea87c21.webp
Normal file
1
public/img/fork_KnightCandy_256-fea87c21.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_LunarPhase_256-58ab7825.webp
Normal file
1
public/img/fork_LunarPhase_256-58ab7825.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_MotorCandy_256-fd81bc62.webp
Normal file
1
public/img/fork_MotorCandy_256-fd81bc62.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Nakupeda_256-96338388.webp
Normal file
1
public/img/fork_Nakupeda_256-96338388.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_NestBird_256-f6e28be2.webp
Normal file
1
public/img/fork_NestBird_256-f6e28be2.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_PaperPlane_256-2d4e5717.webp
Normal file
1
public/img/fork_PaperPlane_256-2d4e5717.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_PoliceRat_256-d05f72e2.webp
Normal file
1
public/img/fork_PoliceRat_256-d05f72e2.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Qiaoqiao_256-5fad717f.webp
Normal file
1
public/img/fork_Qiaoqiao_256-5fad717f.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Rose_256-d33fcc29.webp
Normal file
1
public/img/fork_Rose_256-d33fcc29.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_ThiefCandy_256-9b9444e3.webp
Normal file
1
public/img/fork_ThiefCandy_256-9b9444e3.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_TigerTally_256-47cfb268.webp
Normal file
1
public/img/fork_TigerTally_256-47cfb268.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Time_256-9cb02755.webp
Normal file
1
public/img/fork_Time_256-9cb02755.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_Whale_256-76862d07.webp
Normal file
1
public/img/fork_Whale_256-76862d07.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_bopu_256-e045cc0d.webp
Normal file
1
public/img/fork_bopu_256-e045cc0d.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_dustbin_256-6595915b.webp
Normal file
1
public/img/fork_dustbin_256-6595915b.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_fudianling_256-fe144d13.webp
Normal file
1
public/img/fork_fudianling_256-fe144d13.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_jiaojuan_256-769fdf83.webp
Normal file
1
public/img/fork_jiaojuan_256-769fdf83.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_jingmotingyuan_256-eccbc72b.webp
Normal file
1
public/img/fork_jingmotingyuan_256-eccbc72b.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_koinobori_256-c8624200.webp
Normal file
1
public/img/fork_koinobori_256-c8624200.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_lingganzhongjiezhe_256-dd4950fd.webp
Normal file
1
public/img/fork_lingganzhongjiezhe_256-dd4950fd.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_mamen_256-2d0a8b9a.webp
Normal file
1
public/img/fork_mamen_256-2d0a8b9a.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_mofeikesi_256-db984ecf.webp
Normal file
1
public/img/fork_mofeikesi_256-db984ecf.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_moon_256-19817a0c.webp
Normal file
1
public/img/fork_moon_256-19817a0c.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_nonos_256-4cb5e0fa.webp
Normal file
1
public/img/fork_nonos_256-4cb5e0fa.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_oulaquantao_256-bcc71d33.webp
Normal file
1
public/img/fork_oulaquantao_256-bcc71d33.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_rishi_256-e4137b85.webp
Normal file
1
public/img/fork_rishi_256-e4137b85.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_snowman_256-013ccbd0.webp
Normal file
1
public/img/fork_snowman_256-013ccbd0.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_spider_256-656150ea.webp
Normal file
1
public/img/fork_spider_256-656150ea.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_tuansanlang_256-937209b3.webp
Normal file
1
public/img/fork_tuansanlang_256-937209b3.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_worldrain_256-cf0b4b4a.webp
Normal file
1
public/img/fork_worldrain_256-cf0b4b4a.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_wuhuakuang_256-ea10c6c4.webp
Normal file
1
public/img/fork_wuhuakuang_256-ea10c6c4.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_wushoutieyu_256-d83ef63e.webp
Normal file
1
public/img/fork_wushoutieyu_256-d83ef63e.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_yaodao_256-e8f57727.webp
Normal file
1
public/img/fork_yaodao_256-e8f57727.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_yuanheti_256-087d4df9.webp
Normal file
1
public/img/fork_yuanheti_256-087d4df9.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/fork_yuren_256-727dde45.webp
Normal file
1
public/img/fork_yuren_256-727dde45.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_003-ace97f94.webp
Normal file
1
public/img/player_003-ace97f94.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_004-874ed495.webp
Normal file
1
public/img/player_004-874ed495.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_005-712a486b.webp
Normal file
1
public/img/player_005-712a486b.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_008-4976544f.webp
Normal file
1
public/img/player_008-4976544f.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_009-b9f7ef78.webp
Normal file
1
public/img/player_009-b9f7ef78.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_010-924dc73d.webp
Normal file
1
public/img/player_010-924dc73d.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_012-36c8b09f.webp
Normal file
1
public/img/player_012-36c8b09f.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_013-bf2054be.webp
Normal file
1
public/img/player_013-bf2054be.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_015-ddebe8cc.webp
Normal file
1
public/img/player_015-ddebe8cc.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_019-a75c3fbd.webp
Normal file
1
public/img/player_019-a75c3fbd.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_020-3e088a55.webp
Normal file
1
public/img/player_020-3e088a55.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_021-6cfb115e.webp
Normal file
1
public/img/player_021-6cfb115e.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_027-b3e5e8dc.webp
Normal file
1
public/img/player_027-b3e5e8dc.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_028-79597e4e.webp
Normal file
1
public/img/player_028-79597e4e.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_046-b5837c27.webp
Normal file
1
public/img/player_046-b5837c27.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_073-6a824f1f.webp
Normal file
1
public/img/player_073-6a824f1f.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_104-e857d709.webp
Normal file
1
public/img/player_104-e857d709.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_canhong-8fd9dc5e.webp
Normal file
1
public/img/player_canhong-8fd9dc5e.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_haiyue-28720124.webp
Normal file
1
public/img/player_haiyue-28720124.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_kaesi1-043cca1a.webp
Normal file
1
public/img/player_kaesi1-043cca1a.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_lingke-a6dfa678.webp
Normal file
1
public/img/player_lingke-a6dfa678.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_yiluoyi-2eeff6ec.webp
Normal file
1
public/img/player_yiluoyi-2eeff6ec.webp
Normal file
File diff suppressed because one or more lines are too long
1
public/img/player_zhenhong-1c5f3031.webp
Normal file
1
public/img/player_zhenhong-1c5f3031.webp
Normal file
File diff suppressed because one or more lines are too long
161
src/db/adapters/idb.ts
Normal file
161
src/db/adapters/idb.ts
Normal 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();
|
||||||
|
}
|
||||||
74
src/db/adapters/local-storage.ts
Normal file
74
src/db/adapters/local-storage.ts
Normal 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
21
src/db/adapters/memory.ts
Normal 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
40
src/db/adapters/types.ts
Normal 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
106
src/db/dbfile.ts
Normal 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
306
src/db/import.ts
Normal 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
137
src/db/schema.ts
Normal 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
277
src/db/store.ts
Normal 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
153
src/domain/arcs.ts
Normal 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
132
src/domain/board.ts
Normal 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
65
src/domain/cartridges.ts
Normal 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
85
src/domain/guides.ts
Normal 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
136
src/domain/items.ts
Normal 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
76
src/domain/render.ts
Normal 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
220
src/domain/scoring.ts
Normal 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
66
src/domain/setbonus.ts
Normal 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
96
src/domain/shapes.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue