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
91952b1745
157 changed files with 92600 additions and 0 deletions
153
src/domain/stats.ts
Normal file
153
src/domain/stats.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* The stat model, ported from `stats.py`.
|
||||
*
|
||||
* Two rules govern everything here, and both were expensive to learn:
|
||||
*
|
||||
* 1. **Sheets must be read with the character OFF the active team.** On field an
|
||||
* Arc's conditional effects are folded into the displayed numbers with no way
|
||||
* to separate them from gear. Zankou reads 75% / 203.20% on field and
|
||||
* 59% / 200.00% off it.
|
||||
* 2. **One character cannot separate a flat bonus from a multiplicative one.**
|
||||
* An earlier model fitted a flat base-stat bonus to Zankou alone and was
|
||||
* simply wrong; a second character falsified it outright. Never confirm a
|
||||
* model shape against a single reading.
|
||||
*/
|
||||
import type { ShapeId } from "./shapes.ts";
|
||||
|
||||
export const BASE_CRIT_RATE = 0.05;
|
||||
export const BASE_CRIT_DAMAGE = 0.5;
|
||||
export const BASE_CYCLE_INTENSITY = 100;
|
||||
export const BASE_CHARGE_EFFICIENCY = 1;
|
||||
|
||||
/** A measured value, or the band a rounded curve leaves it in. */
|
||||
export type Measured = number | { readonly min: number; readonly max: number };
|
||||
|
||||
export const midpoint = (value: Measured): number =>
|
||||
typeof value === "number" ? value : (value.min + value.max) / 2;
|
||||
|
||||
/**
|
||||
* base(stat) = floor(curve[level - 1] * M(level, stat))
|
||||
*
|
||||
* M does not depend on the character: Haniel and Adler both give 1.48777 at
|
||||
* level 50 to five decimals while their flat deltas differ, so one table indexed
|
||||
* by level covers all 23 espers.
|
||||
*
|
||||
* HP and DEF track together; ATK runs lower, and at level 50 the bands do not
|
||||
* overlap (~1.4878 vs ~1.4380), so the split is real rather than rounding.
|
||||
*/
|
||||
export const MULTIPLIER: Record<number, Record<"HP" | "ATK" | "DEF", Measured>> = {
|
||||
// The level-50 HP figure is a band, not the five-decimal 1.48777 the Python
|
||||
// records: that value floors Adler to 8637 against a measured 8638. Two
|
||||
// readings pin it to [8638/5806, 8273/5560), and the midpoint reproduces both.
|
||||
50: {
|
||||
HP: { min: 1.4877713, max: 1.4879435 },
|
||||
ATK: { min: 1.43684, max: 1.43925 },
|
||||
DEF: { min: 1.48883, max: 1.49128 },
|
||||
},
|
||||
70: { HP: 1.61347, ATK: 1.56369, DEF: 1.6135 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Level 80 is the level builds actually use and it has never been measured. One
|
||||
* gearless, off-team reading of any character at 80 fills it in for everyone.
|
||||
*/
|
||||
export const UNMEASURED_LEVELS = [80];
|
||||
|
||||
export function hasMultiplier(level: number): boolean {
|
||||
return level in MULTIPLIER;
|
||||
}
|
||||
|
||||
export function baseStat(
|
||||
curve: readonly number[],
|
||||
level: number,
|
||||
stat: "HP" | "ATK" | "DEF",
|
||||
): number | null {
|
||||
const row = MULTIPLIER[level];
|
||||
const value = curve[level - 1];
|
||||
if (!row || value === undefined) return null;
|
||||
return Math.floor(value * midpoint(row[stat]));
|
||||
}
|
||||
|
||||
/**
|
||||
* total ATK = (base + arc) * (1 + atkPercent) + flat
|
||||
*
|
||||
* The Arc's ATK scales with ATK%; flat gear ATK does not. A rival fit of
|
||||
* `base * (1 + pct) + arc + flat` also lands on the sheet exactly - the sheet
|
||||
* truncates, so an exact hit is no stronger evidence than a truncated one, and
|
||||
* only a fully unequipped reading can settle it.
|
||||
*/
|
||||
export function totalAtk(base: number, arc: number, flat: number, atkPercent: number): number {
|
||||
return (base + arc) * (1 + atkPercent) + flat;
|
||||
}
|
||||
|
||||
export interface TraitStat {
|
||||
id_stats: string;
|
||||
name: string;
|
||||
bShowPercent: boolean;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ConsoleTrait {
|
||||
/** Raw stat id, e.g. `CritDamageBase`. */
|
||||
stat: string;
|
||||
name: string;
|
||||
/** Per qualifying module: already divided by 100 when the stat is a percent. */
|
||||
per: number;
|
||||
/**
|
||||
* The module *cell count* the trait counts - 2 or 3. This is everness's
|
||||
* `OwnerGridCount`, which is NOT the grid type despite the name; Prydwen's
|
||||
* "Console Grid Type" means this same number.
|
||||
*/
|
||||
moduleCells: number;
|
||||
}
|
||||
|
||||
export interface EsperTraitSource {
|
||||
trait: readonly TraitStat[];
|
||||
ownerGridCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trait from the data, never from a table.
|
||||
*
|
||||
* It is not always Type III and not always CRIT DMG: three characters key on
|
||||
* Type II, and the values run 6 to 16 across eight different stats.
|
||||
*/
|
||||
export function consoleTrait(esper: EsperTraitSource): ConsoleTrait | null {
|
||||
const stat = esper.trait[0];
|
||||
if (!stat) return null;
|
||||
return {
|
||||
stat: stat.id_stats,
|
||||
name: stat.name,
|
||||
per: stat.bShowPercent ? stat.value / 100 : stat.value,
|
||||
moduleCells: esper.ownerGridCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the console trait contributes for a given set of modules on the board. */
|
||||
export function traitContribution(
|
||||
trait: ConsoleTrait | null,
|
||||
shapes: readonly ShapeId[],
|
||||
): { stat: string; value: number } | null {
|
||||
if (!trait) return null;
|
||||
let qualifying = 0;
|
||||
for (const shape of shapes) {
|
||||
if (Number(shape.slice(4, 5)) === trait.moduleCells) qualifying += 1;
|
||||
}
|
||||
return { stat: trait.stat, value: trait.per * qualifying };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ravenous Blade's +16% CRIT Rate needs the Arc equipped AND the character on
|
||||
* field, isolated by three readings: arc off / on field 35%, arc on / off field
|
||||
* 59%, arc on / on field 75%.
|
||||
*/
|
||||
export const ARC_ON_FIELD_EFFECTS: Record<string, Record<string, number>> = {
|
||||
"Ravenous Blade": { CritBase: 0.16 },
|
||||
};
|
||||
|
||||
/**
|
||||
* +3.2% CRIT DMG appears in both on-field readings, with and without the Arc, so
|
||||
* it is neither gear nor the Arc. Source unidentified; harmless as long as
|
||||
* sheets are read off-team, which is why the app never models on-field numbers.
|
||||
*/
|
||||
export const ON_FIELD_UNKNOWN: Record<string, number> = { CritDamageBase: 0.032 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue