nte-optimizer/src/domain/stats.ts
goober 91952b1745 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>
2026-08-21 11:38:17 +03:00

153 lines
5.3 KiB
TypeScript

/**
* 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 };