nte-optimizer/src/ui/Board.tsx
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

60 lines
1.7 KiB
TypeScript

/**
* A character's console grid.
*
* Blocked cells are drawn as holes, free cells as the piece covering them, and
* borders only where a piece meets something else - so a build reads as six to
* eight objects rather than twenty squares.
*/
import { pieceColour, renderBoard } from "../domain/render.ts";
export interface BoardProps {
cells: readonly number[];
placement: readonly number[];
pieces: number;
/** Called with the piece index under the pointer, or null. */
onHover?: (piece: number | null) => void;
labelFor?: (piece: number) => string;
size?: number;
}
export function Board({
cells,
placement,
pieces,
onHover,
labelFor,
size = 34,
}: BoardProps) {
const render = renderBoard(cells, placement);
const free = new Set(cells);
return (
<div
className="board"
style={{ gridTemplateColumns: `repeat(${render.cols}, ${size}px)` }}
onPointerLeave={() => onHover?.(null)}
>
{Array.from({ length: render.rows * render.cols }, (_, index) => {
if (!free.has(index)) {
return <div key={index} className="board-cell board-cell--blocked" />;
}
const cell = render.cells.find((entry) => entry.cell === index)!;
return (
<div
key={index}
className="board-cell"
title={labelFor?.(cell.piece)}
onPointerEnter={() => onHover?.(cell.piece)}
style={{
background: pieceColour(cell.piece, pieces),
borderTopWidth: cell.top ? 2 : 0,
borderRightWidth: cell.right ? 2 : 0,
borderBottomWidth: cell.bottom ? 2 : 0,
borderLeftWidth: cell.left ? 2 : 0,
}}
/>
);
})}
</div>
);
}