Compare commits
No commits in common. "e922a995ee2aa1b83d9a014124fedcfd00e1b3d4" and "1c6a53096dc0711366fdd410a083dff70f12df41" have entirely different histories.
e922a995ee
...
1c6a53096d
20 changed files with 1546 additions and 265 deletions
113
README.md
113
README.md
|
|
@ -1,76 +1,63 @@
|
|||
# nte-optimizer
|
||||
|
||||
Gear optimizer for *Neverness to Everness*. Static site, no server, no account.
|
||||
> [!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.
|
||||
|
||||
It imports the JSON that [`nte-exporter`](../nte-exporter) writes during a normal
|
||||
capture run, and recommends what to equip on whom — drawn onto each character's
|
||||
console grid.
|
||||
Gear optimizer for *Neverness to Everness*. Static site, no server, no account, and no runtime connection to the game.
|
||||
|
||||
## Running it
|
||||
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 # development
|
||||
npm run build # dist/index.html — the normal build
|
||||
npm run build:single # dist/nte.html — one file, works over file://
|
||||
npm test
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`dist/` is **committed on purpose**. In three years, when `npm ci` fails on some
|
||||
transitive native module, `python3 -m http.server -d dist` will still work. The
|
||||
source build is the convenience; the committed output is the artifact.
|
||||
Useful checks and builds:
|
||||
|
||||
## Layout
|
||||
```sh
|
||||
npm test
|
||||
npm run check:guides
|
||||
npm run build # hosted build in dist/
|
||||
npm run build:single # standalone dist/nte.html
|
||||
```
|
||||
|
||||
| path | what lives there |
|
||||
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/` | raw everness mirrors, Arc classifications, guide data. Committed, not shipped. |
|
||||
| `tools/` | build-time scripts. Output goes to `src/generated/`, also committed. |
|
||||
| `src/domain/` | **pure.** No DOM, no IndexedDB, no imports from `ui/`, `db/` or `state/`. |
|
||||
| `src/solver/` | same rule. Chunked generators, so they run in a worker or inline unchanged. |
|
||||
| `src/db/` | schema, migrations, import, persistence adapters. |
|
||||
| `src/ui/` | React. Four tabs and a hash router. |
|
||||
| `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. |
|
||||
|
||||
The `domain/` and `solver/` rule is what makes them worker-safe, Node-testable
|
||||
and diffable against the Python they were ported from. Keep it.
|
||||
|
||||
## Two things that are easy to get wrong
|
||||
|
||||
**Ported rules were expensive to derive and break in ways that still look
|
||||
plausible.** Before touching anything in `domain/`, read
|
||||
`~/Apps/nte-research/WIREFORMAT.md` and the "facts worth not re-deriving"
|
||||
section of `RESUME.md`. Builds are 6, 7 or 8 modules and never always 7; the
|
||||
console trait is not always CRIT DMG or Type III; a transposed board renders
|
||||
correctly on a symmetric grid.
|
||||
|
||||
**The model has measured gaps, and the app is built to show them rather than
|
||||
hide them.** Set bonus values are unknown and contribute nothing. The base-stat
|
||||
multiplier has never been read at level 80. A `proven` build is optimal for its
|
||||
packing and cartridge, not globally. Every one of those is surfaced in the UI on
|
||||
purpose — if you find yourself replacing one with a plausible number, don't.
|
||||
|
||||
## Degraded modes
|
||||
|
||||
Opening `dist/nte.html` from the filesystem gives the page an opaque origin, so
|
||||
IndexedDB, workers, `fetch` and module scripts are all unavailable. The app
|
||||
handles this behind two interfaces it never sees through — `PersistenceAdapter`
|
||||
(IndexedDB → localStorage → memory) and `SolverHost` (worker → inline) — and
|
||||
shows a banner when storage is not durable. Artwork is not carried in that build.
|
||||
|
||||
## Deploying
|
||||
|
||||
`npm run deploy` builds both targets and publishes to
|
||||
**https://nte.goober.moe**.
|
||||
|
||||
The homelab has no `rsync`, so the tree goes over as a tar stream, and the
|
||||
destination is wiped first: a stale content-hashed chunk left behind would be
|
||||
served forever, since `/assets/*` is cached as immutable. Caddy serves
|
||||
`/mnt/docker/appdata/caddy/sites/nte.goober.moe` as `/srv/nte.goober.moe` via a
|
||||
read-only bind mount, which is why a deploy needs no container restart — but
|
||||
adding a *new* site does, and recreating that container briefly drops every
|
||||
other site behind Caddy.
|
||||
|
||||
`https://nte.goober.moe/nte.html` is the single-file offline build, shipped
|
||||
alongside the app.
|
||||
|
||||
`dist/` is not in git. The deploy builds from source.
|
||||
`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.
|
||||
|
|
|
|||
1194
data-src/guides.json
1194
data-src/guides.json
File diff suppressed because it is too large
Load diff
|
|
@ -1,127 +1,55 @@
|
|||
{
|
||||
"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": []
|
||||
}
|
||||
"format": "nte-set-bonuses",
|
||||
"format_version": 2,
|
||||
"source": "Prydwen, read 2026-08-21; page updated 2026-04-26",
|
||||
"sets": {
|
||||
"Crimson: Twin Butterflies": {
|
||||
"2": {"description": "Incantation DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpIncantationBase", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "ATK +6% per nearby team Incantation DMG trigger, up to 6 stacks; each lasts 10s.", "mode": "stacks", "max_stacks": 6, "stats": [{"stat": "AtkUp", "value": 0.06}], "why": "Wearer ATK stacks to 6 and remains active off-field."}
|
||||
},
|
||||
"Devil's Blood: Curse": {
|
||||
"2": {"description": "Psyche DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsycheBase", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "DMG +18%, increased to 36% against units affected by Nova or Stain.", "mode": "always", "stats": [{"stat": "DamageUpGeneralBase", "value": 0.18}], "why": "The unconditional 18% is modelled; the target-conditional increase is not assumed."}
|
||||
},
|
||||
"Diabolos": {
|
||||
"2": {"description": "Chaos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpChaosBase", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "Ignores 12% Chaos RES, increased to 24% for 20s after Nova or Scorch.", "mode": "unmodellable", "stats": [], "why": "Chaos RES is not DEF; mapping this to DefIgnore would model a different quantity."}
|
||||
},
|
||||
"Fireflies and the Forest": {
|
||||
"2": {"description": "Anima DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpNatureBase", "value": 0.1}], "why": "Anima is named Nature in the stat ids."},
|
||||
"4": {"description": "CRIT DMG +8% per nearby team Anima DMG trigger, up to 7 stacks; each lasts 10s.", "mode": "stacks", "max_stacks": 7, "stats": [{"stat": "CritDamageBase", "value": 0.08}], "why": "Wearer CRIT DMG stacks to 7 and remains active off-field."}
|
||||
},
|
||||
"Kingdom's Guard": {
|
||||
"2": {"description": "DEF +15%", "mode": "always", "stats": [{"stat": "DefUp", "value": 0.15}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "Shields +20%", "mode": "unmodellable", "stats": [], "why": "Shield strength has no stat-vector slot."}
|
||||
},
|
||||
"Lost Radiance": {
|
||||
"2": {"description": "Cosmos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpCosmosBase", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "Ignores 25% DEF for 20s after Ultimate.", "mode": "duration", "stats": [{"stat": "DefIgnore", "value": 0.25}], "why": "Timed post-Ultimate stat; uptime needs rotation data, so it is reported but not scored."}
|
||||
},
|
||||
"Quiet Manor": {
|
||||
"2": {"description": "Mental DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsychicallyBase", "value": 0.1}], "why": "User-decided circumstantial mapping: DamageUpPsychicallyBase is the only non-element damage id, appears on 19 real items, and Fadia's guide pairs Mental DMG with Quiet Manor. Not sheet-confirmed; check in game."},
|
||||
"4": {"description": "Mental DMG +12% per Basic Attack, up to 3 stacks; each lasts 6s.", "mode": "stacks", "max_stacks": 3, "stats": [{"stat": "DamageUpPsychicallyBase", "value": 0.12}], "why": "Uses the same user-decided Mental DMG mapping as the 2-piece tier; stacks to 3."}
|
||||
},
|
||||
"Shadow Creed": {
|
||||
"2": {"description": "ATK +10%", "mode": "always", "stats": [{"stat": "AtkUp", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "ATK +25% for 20s after Skill.", "mode": "duration", "stats": [{"stat": "AtkUp", "value": 0.25}], "why": "Timed post-Skill stat; uptime needs rotation data, so it is reported but not scored."}
|
||||
},
|
||||
"Speedy Hedgehog": {
|
||||
"2": {"description": "Charge Efficiency +12%", "mode": "always", "stats": [{"stat": "ChargeGetEfficiencyBase", "value": 0.12}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "All allies' ATK +15% for 20s after wearer uses Ultimate.", "mode": "unmodellable", "stats": [], "why": "Buff applies to allies, not the wearer being optimised."}
|
||||
},
|
||||
"Street Boxer": {
|
||||
"2": {"description": "Lakshana DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpLakshanaBase", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "CRIT Rate +14%.", "mode": "always", "stats": [{"stat": "CritBase", "value": 0.14}], "why": "Prydwen's reaction sentence repeats 14% from 14%; treated as a source typo, not a second value."}
|
||||
},
|
||||
"Thea's Night Tavern": {
|
||||
"2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "Healing Bonus +20%", "mode": "always", "stats": [{"stat": "HealUp", "value": 0.2}], "why": "Unconditional wearer stat."}
|
||||
},
|
||||
"Tiny Big Adventure": {
|
||||
"2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||
"4": {"description": "Max HP +4% when taking damage, up to 10 stacks; each lasts 10s; Ultimate grants 10.", "mode": "stacks", "max_stacks": 10, "stats": [{"stat": "HPMaxUp", "value": 0.04}], "why": "Wearer Max HP stacks to 10; targeting the 4-piece tier uses its stated maximum."}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@
|
|||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"check:guides": "node --experimental-strip-types tools/check-guides.ts",
|
||||
"deploy": "sh tools/deploy.sh"
|
||||
"check:guides": "node --experimental-strip-types tools/check-guides.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb": "8.0.3",
|
||||
|
|
|
|||
|
|
@ -21,11 +21,26 @@ export interface GuideVariant {
|
|||
priority: string[];
|
||||
}
|
||||
|
||||
/** A recommended cartridge set, in the order the guide ranks them. */
|
||||
export interface GuideSet {
|
||||
rank: number;
|
||||
/** The published name, which is also its key in `set-bonuses.json`. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CharacterGuide {
|
||||
/** The `GA_<key>_*` ability key, so it joins to a capture directly. */
|
||||
key: string;
|
||||
source: string;
|
||||
updated: string;
|
||||
/**
|
||||
* The guide's ranked cartridge recommendation, recorded as published.
|
||||
*
|
||||
* Nothing in the solve reads this: the solver enumerates every set the player
|
||||
* owns and is free to disagree, which is the point of running it. It is kept
|
||||
* so the published recommendation can be shown beside the solved build.
|
||||
*/
|
||||
sets?: GuideSet[];
|
||||
variants: GuideVariant[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,18 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cartridge set bonuses, classified with the same modes as Arc effects. */
|
||||
import { activeTiers, type SetName, type Tier } from "./cartridges.ts";
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
import { vectorFrom, type StatPair } from "./statvec.ts";
|
||||
import { vectorFrom, type StatId, type StatPair } from "./statvec.ts";
|
||||
|
||||
export type BonusMode = "always" | "toggle" | "stacks" | "duration" | "unmodellable";
|
||||
|
||||
export interface TierBonus {
|
||||
unknown: boolean;
|
||||
stats: StatPair[];
|
||||
description: string;
|
||||
mode: BonusMode;
|
||||
stats: Array<{ stat: StatId; value: number }>;
|
||||
why: string;
|
||||
max_stacks?: number;
|
||||
/** Kept for future source gaps; v2 classifies every current tier. */
|
||||
unknown?: boolean;
|
||||
}
|
||||
|
||||
export interface SetBonusTable {
|
||||
|
|
@ -26,11 +21,17 @@ export interface SetBonusTable {
|
|||
sets: Record<string, Record<"2" | "4", TierBonus>>;
|
||||
}
|
||||
|
||||
export interface OmittedTier {
|
||||
tier: Tier;
|
||||
mode: "duration" | "unmodellable";
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface SetBonusResult {
|
||||
tiers: Tier[];
|
||||
vector: Float32Array;
|
||||
/** Tiers that are active but whose values nobody has measured yet. */
|
||||
unknownTiers: Tier[];
|
||||
omittedTiers: OmittedTier[];
|
||||
}
|
||||
|
||||
/** What a board's set bonuses contribute, and what is missing from that answer. */
|
||||
|
|
@ -38,29 +39,41 @@ export function setBonus(
|
|||
table: SetBonusTable,
|
||||
set: SetName,
|
||||
shapesOnBoard: readonly ShapeId[],
|
||||
targetTier: Tier = 4,
|
||||
): SetBonusResult {
|
||||
const tiers = activeTiers(set, shapesOnBoard);
|
||||
const entry = table.sets[set];
|
||||
const pairs: StatPair[] = [];
|
||||
const unknownTiers: Tier[] = [];
|
||||
const omittedTiers: OmittedTier[] = [];
|
||||
|
||||
for (const tier of tiers) {
|
||||
if (tier > targetTier) continue;
|
||||
const bonus = entry?.[String(tier) as "2" | "4"];
|
||||
if (!bonus || bonus.unknown) {
|
||||
unknownTiers.push(tier);
|
||||
continue;
|
||||
}
|
||||
pairs.push(...bonus.stats);
|
||||
if (bonus.mode === "duration" || bonus.mode === "unmodellable") {
|
||||
omittedTiers.push({ tier, mode: bonus.mode, why: bonus.why });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ponytail: selecting 2+4 means aiming for stated max stacks; add a stack control only if users need partial uptime.
|
||||
const multiplier = bonus.mode === "stacks" ? bonus.max_stacks ?? 0 : 1;
|
||||
pairs.push(...bonus.stats.map(({ stat, value }) => ({ stat, value: value * multiplier })));
|
||||
}
|
||||
|
||||
return { tiers, vector: vectorFrom(pairs), unknownTiers };
|
||||
return { tiers, vector: vectorFrom(pairs), unknownTiers, omittedTiers };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!tiers["2"] || !tiers["4"] || tiers["2"].unknown || tiers["4"].unknown) {
|
||||
out.push(set as SetName);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* host and the worker host run byte-identical code and cannot drift.
|
||||
*/
|
||||
import { boardFromSlots, boardKey } from "../domain/board.ts";
|
||||
import type { SetName } from "../domain/cartridges.ts";
|
||||
import type { SetName, Tier } from "../domain/cartridges.ts";
|
||||
import { buildPool, type ExportedItem } from "../domain/items.ts";
|
||||
import { compile, type ScoringConfig } from "../domain/scoring.ts";
|
||||
import type { SetBonusTable } from "../domain/setbonus.ts";
|
||||
|
|
@ -29,6 +29,8 @@ export interface CharacterJob {
|
|||
scoring: ScoringConfig;
|
||||
/** Restrict to these sets. Omit for every set the account owns. */
|
||||
sets?: SetName[];
|
||||
/** Highest set-bonus tier to score: 2, or both 2 and 4. */
|
||||
targetTier?: Tier;
|
||||
/**
|
||||
* Instances this character may not use - R2's "use equipped items" toggle
|
||||
* turned off, meaning items another character is wearing are off limits.
|
||||
|
|
@ -129,6 +131,7 @@ export function* runJob(job: SolveJob): Generator<JobProgress, JobResult, void>
|
|||
base: baseVector(character),
|
||||
trait: character.trait,
|
||||
setBonuses: job.setBonuses,
|
||||
targetTier: character.targetTier ?? 4,
|
||||
scoring: compile(character.scoring),
|
||||
excluded,
|
||||
beamWidth: options.beamWidth,
|
||||
|
|
|
|||
|
|
@ -33,8 +33,10 @@ export interface Build {
|
|||
* The UI must render the distinction rather than implying certainty.
|
||||
*/
|
||||
proven: boolean;
|
||||
/** Active set tiers whose values nobody has measured yet. */
|
||||
/** Selected active set tiers still lacking source data. */
|
||||
unknownTiers: Tier[];
|
||||
/** Selected tiers that were classified but cannot be scored honestly. */
|
||||
omittedTiers: Array<{ tier: Tier; mode: "duration" | "unmodellable"; why: string }>;
|
||||
}
|
||||
|
||||
export interface SolveResult {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
* for free.
|
||||
*/
|
||||
import type { ConsoleTrait } from "../domain/stats.ts";
|
||||
import type { Tier } from "../domain/cartridges.ts";
|
||||
import type { ItemPool } from "../domain/items.ts";
|
||||
import type { SetBonusTable } from "../domain/setbonus.ts";
|
||||
import type { Tiling } from "../domain/tilings.ts";
|
||||
|
|
@ -46,6 +47,7 @@ export interface SingleRequest {
|
|||
base: Float32Array;
|
||||
trait: ConsoleTrait | null;
|
||||
setBonuses: SetBonusTable;
|
||||
targetTier?: Tier;
|
||||
scoring: CompiledScoring;
|
||||
/** Pool indices held by other characters and therefore off limits. */
|
||||
excluded?: ReadonlySet<number>;
|
||||
|
|
@ -151,6 +153,7 @@ export function* solveSingle(
|
|||
base,
|
||||
trait,
|
||||
setBonuses,
|
||||
targetTier = 4,
|
||||
scoring,
|
||||
excluded = new Set<number>(),
|
||||
beamWidth = DEFAULT_BEAM_WIDTH,
|
||||
|
|
@ -201,7 +204,7 @@ export function* solveSingle(
|
|||
fixed.set(base);
|
||||
const traitStat = traitContribution(trait, tiling.pieces);
|
||||
if (traitStat) vectorFrom([traitStat], fixed);
|
||||
const bonus = setBonus(setBonuses, tiling.set, tiling.pieces);
|
||||
const bonus = setBonus(setBonuses, tiling.set, tiling.pieces, targetTier);
|
||||
for (let i = 0; i < SLOT_COUNT; i += 1) fixed[i] = fixed[i]! + bonus.vector[i]!;
|
||||
|
||||
// Hardest groups first: fewer choices near the root prunes the most.
|
||||
|
|
@ -270,6 +273,7 @@ export function* solveSingle(
|
|||
vector,
|
||||
proven: false,
|
||||
unknownTiers: bonus.unknownTiers,
|
||||
omittedTiers: bonus.omittedTiers,
|
||||
};
|
||||
}
|
||||
if (bestForState === null) continue;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react";
|
|||
|
||||
import { arcContribution, arcControls, MAX_ARC_LEVEL, MAX_REFINEMENT } from "../domain/arcs.ts";
|
||||
import { boardFromSlots } from "../domain/board.ts";
|
||||
import { SET_NAMES, type SetName, type Tier } from "../domain/cartridges.ts";
|
||||
import { compile, explain, type StatTarget } from "../domain/scoring.ts";
|
||||
import { guideFor, targetsFromGuide, variantFor } from "../domain/guides.ts";
|
||||
import { compareSheet, predictSheet } from "../domain/sheet.ts";
|
||||
|
|
@ -89,6 +90,11 @@ export function CharacterView({
|
|||
// R2. Off by default: taking gear off another character is a real cost, so it
|
||||
// is opted into rather than assumed.
|
||||
const [useEquipped, setUseEquipped] = useState(false);
|
||||
const [setName, setSetName] = useState<SetName | "">(() => {
|
||||
const recommended = guide?.sets?.[0]?.name;
|
||||
return recommended && recommended in data.setBonuses.sets ? (recommended as SetName) : "";
|
||||
});
|
||||
const [targetTier, setTargetTier] = useState<Tier>(2);
|
||||
const handle = useRef<SolveHandle | null>(null);
|
||||
|
||||
const worn = equipmentOf(db, characterId);
|
||||
|
|
@ -154,6 +160,8 @@ export function CharacterView({
|
|||
trait,
|
||||
base: arcResult ? Array.from(arcResult.vector) : new Array(SLOT_COUNT).fill(0),
|
||||
scoring: { targets },
|
||||
...(setName ? { sets: [setName] } : {}),
|
||||
targetTier,
|
||||
excludedInstances: useEquipped ? [] : heldByOthers(db, [characterId]),
|
||||
},
|
||||
],
|
||||
|
|
@ -318,6 +326,33 @@ export function CharacterView({
|
|||
<ReadOnlyTargets targets={targets} source={guide!.source} updated={guide!.updated} />
|
||||
)}
|
||||
|
||||
<div className="arc-row">
|
||||
<label className="arc">
|
||||
Cartridge set
|
||||
<select
|
||||
value={setName}
|
||||
disabled={running}
|
||||
onChange={(event) => setSetName(event.target.value as SetName | "")}
|
||||
>
|
||||
<option value="">Any owned set</option>
|
||||
{SET_NAMES.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="arc">
|
||||
Set bonuses to aim for
|
||||
<select
|
||||
value={targetTier}
|
||||
disabled={running}
|
||||
onChange={(event) => setTargetTier(Number(event.target.value) as Tier)}
|
||||
>
|
||||
<option value={2}>2-piece only</option>
|
||||
<option value={4}>2-piece + 4-piece</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -401,6 +436,11 @@ export function CharacterView({
|
|||
never been measured, so it contributes nothing to this score.
|
||||
</p>
|
||||
)}
|
||||
{build.omittedTiers.length > 0 && (
|
||||
<p className="warning">
|
||||
Not modelled: {build.omittedTiers.map((entry) => `tier ${entry.tier}: ${entry.why}`).join("; ")}
|
||||
</p>
|
||||
)}
|
||||
{report && (
|
||||
<table className="items">
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -306,6 +306,11 @@ function AssignedBuild({
|
|||
contributes nothing to this score.
|
||||
</p>
|
||||
)}
|
||||
{build.omittedTiers.length > 0 && (
|
||||
<p className="warning">
|
||||
Not modelled: {build.omittedTiers.map((tier) => `tier ${tier.tier}: ${tier.why}`).join("; ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<BuildLegend
|
||||
cells={build.tiling.cells}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
--panel: #1c1f26;
|
||||
--line: #2c313b;
|
||||
--text: #e6e8ec;
|
||||
--dim: #9aa2b1;
|
||||
--accent: #7aa2f7;
|
||||
--warn: #e0af68;
|
||||
--dim: #cacdd5;
|
||||
--accent: #bbd0ff;
|
||||
--warn: #f1cc8d;
|
||||
--bad: #f7768e;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
|
@ -59,7 +59,13 @@ select, input, button {
|
|||
font-size: 0.85rem;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: 0.45; cursor: default; }
|
||||
button:hover:not(:disabled), select:hover:not(:disabled), input:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button:active:not(:disabled) { color: var(--accent); }
|
||||
button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button:disabled, select:disabled, input:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
table.items { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
table.items th {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { boardFromSlots, fillers, placements, tile } from "../src/domain/board.t
|
|||
import { REQUIRED_PIECES, SET_IDS, SET_NAMES, activeTiers } from "../src/domain/cartridges.ts";
|
||||
import { incompleteSets, setBonus, type SetBonusTable } from "../src/domain/setbonus.ts";
|
||||
import { ICON_ORDER, SHAPES, renderShape, sizeOf } from "../src/domain/shapes.ts";
|
||||
import { slotOf } from "../src/domain/statvec.ts";
|
||||
import { pieceColour, renderBoard } from "../src/domain/render.ts";
|
||||
import { cellsOfPiece, loadTilings, multisetOf, type RawTilings } from "../src/domain/tilings.ts";
|
||||
|
||||
|
|
@ -182,22 +183,47 @@ describe("set bonuses", () => {
|
|||
expect(Object.keys(SET_IDS)).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("contributes nothing rather than a guess while values are unmeasured", () => {
|
||||
it("classifies every tier and preserves the three honest model gaps", () => {
|
||||
expect(table.format_version).toBe(2);
|
||||
expect(Object.values(table.sets).flatMap((tiers) => Object.values(tiers))).toHaveLength(24);
|
||||
expect(
|
||||
Object.entries(table.sets)
|
||||
.filter(([, tiers]) => tiers["4"].mode === "unmodellable")
|
||||
.map(([set]) => set)
|
||||
.sort(),
|
||||
).toEqual(["Diabolos", "Kingdom's Guard", "Speedy Hedgehog"]);
|
||||
expect(incompleteSets(table)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the user-decided Mental DMG mapping", () => {
|
||||
expect(table.sets["Quiet Manor"]!["2"].stats).toEqual([
|
||||
{ stat: "DamageUpPsychicallyBase", value: 0.1 },
|
||||
]);
|
||||
expect(table.sets["Quiet Manor"]!["2"].why).toContain("circumstantial");
|
||||
});
|
||||
|
||||
it("scores only selected tiers and reports timed tiers it omits", () => {
|
||||
const required = REQUIRED_PIECES["Shadow Creed"];
|
||||
const result = setBonus(table, "Shadow Creed", required);
|
||||
expect(result.tiers).toEqual([2, 4]);
|
||||
expect(result.unknownTiers).toEqual([2, 4]);
|
||||
expect([...result.vector].every((value) => value === 0)).toBe(true);
|
||||
const twoPiece = setBonus(table, "Shadow Creed", required, 2);
|
||||
expect(twoPiece.vector[slotOf("AtkUp")]).toBeCloseTo(0.1);
|
||||
expect(twoPiece.omittedTiers).toEqual([]);
|
||||
|
||||
const full = setBonus(table, "Shadow Creed", required, 4);
|
||||
expect(full.vector[slotOf("AtkUp")]).toBeCloseTo(0.1);
|
||||
expect(full.omittedTiers).toEqual([
|
||||
{ tier: 4, mode: "duration", why: table.sets["Shadow Creed"]!["4"].why },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no active tier when the board misses the shapes", () => {
|
||||
const result = setBonus(table, "Shadow Creed", ["cell3_style1"]);
|
||||
expect(result.tiers).toEqual([]);
|
||||
expect(result.unknownTiers).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags every set as incomplete until the values are filled in", () => {
|
||||
expect(incompleteSets(table)).toHaveLength(12);
|
||||
it("uses stated maximum stacks when aiming for 2+4", () => {
|
||||
const result = setBonus(
|
||||
table,
|
||||
"Crimson: Twin Butterflies",
|
||||
REQUIRED_PIECES["Crimson: Twin Butterflies"],
|
||||
4,
|
||||
);
|
||||
expect(result.vector[slotOf("DamageUpIncantationBase")]).toBeCloseTo(0.1);
|
||||
expect(result.vector[slotOf("AtkUp")]).toBeCloseTo(0.36);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
weightForRank,
|
||||
type GuideTable,
|
||||
} from "../src/domain/guides.ts";
|
||||
import { isKnownStat } from "../src/domain/statvec.ts";
|
||||
|
||||
const table = JSON.parse(readFileSync("data-src/guides.json", "utf8")) as GuideTable;
|
||||
|
||||
|
|
@ -74,7 +75,12 @@ describe("guide to targets", () => {
|
|||
|
||||
describe("guide lookup", () => {
|
||||
it("returns null for a character with no published guide", () => {
|
||||
expect(guideFor(table, "Zankou")).toBeNull();
|
||||
// Linko (`Radio072`) has a Prydwen page, but its Build tab still says the
|
||||
// stats "aren't available yet", so the character is deliberately absent
|
||||
// rather than filled in with something a player could not tell apart from
|
||||
// real guidance.
|
||||
expect(guideFor(table, "Radio072")).toBeNull();
|
||||
expect(guideFor(table, "NotACharacter")).toBeNull();
|
||||
expect(variantFor(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -91,10 +97,45 @@ describe("guide lookup", () => {
|
|||
expect(variantFor(guide, null)!.name).toBe("Main DPS");
|
||||
});
|
||||
|
||||
it("ships an empty table rather than invented guidance", () => {
|
||||
// Prydwen's data could not be obtained; a plausible-looking guess would be
|
||||
// worse than none, because the player could not tell it apart from real.
|
||||
expect(table.characters).toEqual([]);
|
||||
it("carries the published guides, and only stats the model has slots for", () => {
|
||||
expect(table.format).toBe("nte-guides");
|
||||
expect(table.characters.length).toBeGreaterThan(0);
|
||||
|
||||
for (const guide of table.characters) {
|
||||
expect(guide.variants.length).toBeGreaterThan(0);
|
||||
for (const entry of guide.variants) {
|
||||
// A variant with no targets would score nothing: `targetsFromGuide`
|
||||
// reads the ranking only to weight the targets it already has.
|
||||
expect(entry.targets.length).toBeGreaterThan(0);
|
||||
expect(entry.priority.length).toBeGreaterThan(0);
|
||||
expect(new Set(entry.priority).size).toBe(entry.priority.length);
|
||||
|
||||
for (const stat of entry.priority) expect(isKnownStat(stat)).toBe(true);
|
||||
for (const target of entry.targets) {
|
||||
expect(isKnownStat(target.stat)).toBe(true);
|
||||
expect(target.target).toBeGreaterThan(0);
|
||||
// Percentages are fractions here, never "70".
|
||||
if (target.stat.startsWith("DamageUp") || target.stat.endsWith("Up")) {
|
||||
expect(target.target).toBeLessThanOrEqual(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("weights a real entry by its published ranking", () => {
|
||||
const guide = guideFor(table, "Zankou")!;
|
||||
expect(guide).not.toBeNull();
|
||||
// Prydwen ranks Crimson: Twin Butterflies first for Zankou.
|
||||
expect(guide.sets?.[0]?.name).toBe("Crimson: Twin Butterflies");
|
||||
|
||||
const targets = targetsFromGuide(guide.variants[0]!);
|
||||
const byStat = new Map(targets.map((entry) => [entry.stat, entry]));
|
||||
// Their substat ranking opens "Crit DMG % > Cycle Intensity = Crit Rate %".
|
||||
expect(byStat.get("CritDamageBase")!.weight).toBe(TOP_WEIGHT);
|
||||
expect(byStat.get("CritDamageBase")!.weight).toBeGreaterThan(
|
||||
byStat.get("MagBase")!.weight,
|
||||
);
|
||||
expect(byStat.get("MagBase")!.weight).toBeGreaterThan(byStat.get("CritBase")!.weight);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,6 +101,22 @@ describe("runJob", () => {
|
|||
expect(progress.some((p) => p.phase === "team")).toBe(true);
|
||||
});
|
||||
|
||||
it("carries selected set tiers through the job boundary", () => {
|
||||
const twoPiece = job(["Zankou"]);
|
||||
twoPiece.characters[0]!.targetTier = 2;
|
||||
const runTwo = runJob(twoPiece);
|
||||
let step = runTwo.next();
|
||||
while (!step.done) step = runTwo.next();
|
||||
expect(step.value.assignment[0]!.build.omittedTiers).toEqual([]);
|
||||
|
||||
const full = job(["Zankou"]);
|
||||
full.characters[0]!.targetTier = 4;
|
||||
const runFull = runJob(full);
|
||||
let next = runFull.next();
|
||||
while (!next.done) next = runFull.next();
|
||||
expect(next.value.assignment[0]!.build.omittedTiers[0]).toMatchObject({ tier: 4, mode: "duration" });
|
||||
});
|
||||
|
||||
it("gives two characters disjoint items", () => {
|
||||
const run = runJob(job(["Zankou", "Haniel"]));
|
||||
let step = run.next();
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ import type { Tiling } from "../src/domain/tilings.ts";
|
|||
import { solveSingle } from "../src/solver/single.ts";
|
||||
import type { SolveProgress, SolveResult } from "../src/solver/protocol.ts";
|
||||
|
||||
const unknown = {
|
||||
description: "unmeasured",
|
||||
mode: "always",
|
||||
stats: [],
|
||||
why: "test fixture",
|
||||
unknown: true,
|
||||
} as const;
|
||||
const bonuses: SetBonusTable = {
|
||||
format: "nte-set-bonuses",
|
||||
format_version: 1,
|
||||
sets: {
|
||||
"Shadow Creed": { "2": { unknown: true, stats: [] }, "4": { unknown: true, stats: [] } },
|
||||
},
|
||||
format_version: 2,
|
||||
sets: { "Shadow Creed": { "2": unknown, "4": unknown } },
|
||||
};
|
||||
|
||||
const scoring = compile({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function build(score: number, cartridge: number, modules: number[]): Build {
|
|||
vector: new Float32Array(0),
|
||||
proven: true,
|
||||
unknownTiers: [],
|
||||
omittedTiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
/**
|
||||
* Validates `data-src/guides.json`.
|
||||
*
|
||||
* There is deliberately **no scraper**. Prydwen's character pages are
|
||||
* client-rendered, so a plain fetch returns navigation chrome, and their Gatsby
|
||||
* data endpoint answers `410 Gone`. Their robots.txt does allow content pages
|
||||
* with a ten second crawl-delay, so a headless render would be within policy -
|
||||
* but it is not something an unattended build step should be doing, and the plan
|
||||
* always called for this file to be human-reviewed and committed.
|
||||
* There is deliberately **no scraper**. Prydwen sits behind a Cloudflare bot
|
||||
* challenge, and their Gatsby data endpoint answers `410 Gone`. Their
|
||||
* robots.txt does allow content pages with a ten second crawl-delay, so reading
|
||||
* them is within policy - but through an attended browser session, never as an
|
||||
* unattended build step, and the plan always called for this file to be
|
||||
* human-reviewed and committed.
|
||||
*
|
||||
* So the tool checks what a person typed instead: that every stat id has a slot,
|
||||
* that targets look like the units the model expects, and that keys join to real
|
||||
* characters.
|
||||
* So the tool checks the committed file instead: that every stat id has a slot,
|
||||
* that targets look like the units the model expects, that keys join to real
|
||||
* characters, and that a recommended set is one the game actually has.
|
||||
*
|
||||
* node --experimental-strip-types tools/check-guides.ts
|
||||
*/
|
||||
|
|
@ -29,6 +29,8 @@ const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json
|
|||
const keys = new Set<string>(
|
||||
gamedata.espers.map((esper: { abilityKey: string }) => esper.abilityKey),
|
||||
);
|
||||
const setBonuses = JSON.parse(readFileSync(join(root, "data-src/set-bonuses.json"), "utf8"));
|
||||
const setNames = new Set<string>(Object.keys(setBonuses.sets));
|
||||
|
||||
const problems: string[] = [];
|
||||
|
||||
|
|
@ -43,6 +45,14 @@ for (const guide of guides.characters ?? []) {
|
|||
}
|
||||
if (!guide.variants?.length) problems.push(`${where}: no variants`);
|
||||
|
||||
// A recommended set has to be one of the twelve, or the name is a typo and
|
||||
// would never join to `set-bonuses.json`.
|
||||
for (const set of guide.sets ?? []) {
|
||||
if (!setNames.has(set.name)) problems.push(`${where}: not a cartridge set: ${set.name}`);
|
||||
}
|
||||
const ranks = (guide.sets ?? []).map((set) => set.rank);
|
||||
if (new Set(ranks).size !== ranks.length) problems.push(`${where}: duplicate set rank`);
|
||||
|
||||
for (const variant of guide.variants ?? []) {
|
||||
const scope = `${where}/${variant.name}`;
|
||||
if (!variant.name) problems.push(`${scope}: variant has no name`);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
#!/usr/bin/env sh
|
||||
# Build and publish to nte.goober.moe.
|
||||
#
|
||||
# The homelab has no rsync, so the tree goes over as a tar stream. Caddy serves
|
||||
# /mnt/docker/appdata/caddy/sites/nte.goober.moe as /srv/nte.goober.moe (a
|
||||
# read-only bind mount declared in /mnt/docker/compose/caddy/docker-compose.yml),
|
||||
# so a deploy is a file copy - no container restart, no config reload.
|
||||
# Build and publish to a user-supplied static host.
|
||||
set -eu
|
||||
|
||||
HOST="${DEPLOY_HOST:-hl}"
|
||||
DEST="${DEPLOY_DEST:-/mnt/docker/appdata/caddy/sites/nte.goober.moe}"
|
||||
: "${DEPLOY_HOST:?set DEPLOY_HOST}"
|
||||
: "${DEPLOY_DEST:?set DEPLOY_DEST}"
|
||||
HOST="$DEPLOY_HOST"
|
||||
DEST="$DEPLOY_DEST"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
cd "$ROOT"
|
||||
|
|
@ -20,6 +17,6 @@ npm run build:single
|
|||
# served forever, since /assets/* is cached as immutable.
|
||||
tar -C dist -cz . | ssh "$HOST" "rm -rf '$DEST'/* && tar -C '$DEST' -xz"
|
||||
|
||||
printf '\ndeployed. checking...\n'
|
||||
curl -fsS -o /dev/null -w 'https://nte.goober.moe/ -> %{http_code}\n' https://nte.goober.moe/
|
||||
curl -fsS -o /dev/null -w 'https://nte.goober.moe/nte.html -> %{http_code} (%{size_download} bytes)\n' https://nte.goober.moe/nte.html
|
||||
if [ -n "${DEPLOY_URL:-}" ]; then
|
||||
curl -fsS -o /dev/null -w '%{http_code}\n' "$DEPLOY_URL"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// `base: './'` keeps the build working unchanged at nte.goober.moe, under a
|
||||
// plain `python3 -m http.server -d dist`, and inside the single-file build.
|
||||
// Relative assets keep the build portable across static hosts, local HTTP, and the single-file build.
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue