nte-optimizer/src/domain/render.ts
goober 5ce255fa6f 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

76 lines
2.5 KiB
TypeScript

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