/** * The recommendation: six dice, the numbers behind them, and a badge per tier. */ import type { JSX } from "react"; import type { Die } from "../data/dice.ts"; import type { SolveResponse } from "../core/solve.ts"; export interface ResultPanelProps { readonly result: SolveResponse | null; readonly error: string | null; readonly solving: boolean; } /** * Collapse a loadout into "3x Weighted die, 2x Pie die" form. * * @param dice - The six dice of a loadout. * @returns One entry per distinct die, in descending count order. */ export function summariseDice(dice: readonly Die[]): { die: Die; count: number }[] { const byId = new Map(); for (const die of dice) { const existing = byId.get(die.id); if (existing) { existing.count += 1; } else { byId.set(die.id, { die, count: 1 }); } } return [...byId.values()].sort((a, b) => b.count - a.count); } /** * Format a point figure for display. * * @param value - The number of points. * @returns The value rounded to whole points with thousands separators. */ function points(value: number): string { return Math.round(value).toLocaleString("en-GB"); } /** * The recommendation panel. * * @param props - The latest solve, any error, and whether one is in flight. * @returns The panel element. */ export function ResultPanel({ result, error, solving }: ResultPanelProps): JSX.Element { if (error) { return (

{error}

); } if (!result) { return (

{solving ? "Solving…" : "Add at least six dice to get a recommendation."}

); } return (

Best loadout

Expected score per throw
{points(result.evaluation.ev)}
Bust chance
{(result.evaluation.bustProbability * 100).toFixed(2)}%
Good throw (90th pct)
{points(result.evaluation.p90)}
Simulated points per turn
{points(result.simulation.meanPerTurn)} ± {result.simulation.standardError.toFixed(1)}

{result.optimal ? "Provably optimal: every possible loadout was evaluated." : "Best found by local search — near-optimal, not proven."}{" "} Chosen from {result.inventorySize} dice.

{result.alternatives.length > 0 && (
Runners-up
    {result.alternatives.map((alternative, index) => ( // Alternatives are a fixed ranked list; the index is their rank.
  • {points(alternative.evaluation.ev)} {summariseDice(alternative.dice) .map(({ die, count }) => `${count}× ${die.name}`) .join(", ")}
  • ))}
)}

Best badge per tier

{result.badges.length === 0 ? (

Tick the badges you own to have them ranked.

) : ( result.badges.map((recommendation) => { const [best, ...rest] = recommendation.ranked; return (

{recommendation.tier}

{best.badge.name} {best.pointsPerGame !== null && ( ~{points(best.pointsPerGame)} pts/game )}

{best.reason}

{best.dice && (

With this badge, bring:{" "} {summariseDice(best.dice) .map(({ die, count }) => `${count}× ${die.name}`) .join(", ")}

)} {rest.length > 0 && (
Other {recommendation.tier} badges
    {rest.map((valuation) => (
  • {valuation.badge.name} {valuation.pointsPerGame === null ? " — situational" : ` — ~${points(valuation.pointsPerGame)} pts/game`}
  • ))}
)}
); }) )}
); }