# web_dice — Project Plan & Status

This document summarizes what has been built so far and how the site is put together, as a
snapshot for anyone picking up the project. It supersedes the ad-hoc per-feature plan
documents used during development (kept outside the repo under `~/.claude/plans/`).

## Origin

The site began as a port of a legacy PHP+jQuery dice roller
(`roll_svg.php` / `roll_dice_svg.php` / `yahtzee_svg.php`) into a clean Django application,
with the explicit goals of: vanilla JS (no jQuery), the Django ORM/SQLite for persistence
(the original's hardcoded MySQL credentials were dropped entirely, not carried over), and
full feature parity with the original pages plus a few bug fixes along the way. From there
the site grew a second and third game built on the same shared dice-rolling engine.

## Architecture

Three Django apps share one project (`webdice/`), all built on a common rolling/rendering
core:

- **`dice/engine.py`** — the shared roll/render engine (`parse_dshort`, `roll_die`,
  `resolve_color`, `render_die`, `roll_dshort`). Pure functions, no session/DB dependencies.
  Every game on the site (Dice Sim, Yahtzee, Decathlon) rolls dice through this module, so a
  single implementation produces every SVG die face and every roll's HTML.
- **`dice/models.py`** — `DieFaceStat`, a site-wide, all-time count of how many times each
  face of each die type has ever been rolled, incremented by `roll_dshort()` itself. Every
  page's rolls (not just the Dice Sim page's) contribute to these all-time counts.
- **`dice/svg_data.py` is generated, not hand-written.** The actual source of truth for die
  art is the raw SVGs under `graphics/` (one file per die shape/pip style/overlay).
  `dice/svg_ingest.py` extracts each SVG's fill/stroke/opacity/font attributes into
  `%placeholder%`-tokenized templates and writes them into `svg_data.py`, plus the
  corresponding hold/free hover-overlay CSS blocks in each app's stylesheet. The
  `watchsvg` management command (`dice/management/commands/watchsvg.py`, built on
  `watchdog`) watches `graphics/` during development and re-runs ingestion automatically
  whenever an SVG changes, so editing die art doesn't require manually re-running a script.
- Each of the three apps (`dice/`, `yahtzee/`, `decathlon/`) follows the same shape:
  `models.py` (game-specific persistence, if any), `views.py` (`index` + `roll` + often
  `save_score`), `templates/<app>/index.html`, `static/<app>/{css,js}/`, `tests.py`.
- `templates/base.html` provides the shared page chrome and nav links between the three
  pages.

## Page 1 — Dice Simulator (`dice/`)

A general-purpose dice roller: pulldown and shorthand-notation ("dshort") entry, arbitrary
die counts/sides, pip styles, a 10-slot custom-dice save list, roll history, and both
per-session and all-time face-count statistics tables (one collapsible section per die type,
`d4` through `d20`, using native `<details>/<summary>` — no JS needed for the
expand/collapse itself).

Status: **feature-complete**, full parity with the original PHP page (plus several
fixed bugs — see below) and 23 passing tests. Fully server-rendered on initial load (no
on-load AJAX flurry like the original); a clean JSON contract for the `/roll` endpoint;
vanilla JS throughout (custom tabs, native range slider, custom modal, hold & reroll).

Notable fixes made during the port (not present in the original): the original's d20
percentage-total math double-counted one face instead of summing all 20; the "paydirt"
banner never fired for shorthand-tab rolls in the original; the numeric dice-count/reroll
fields silently accepted non-numeric input due to a `NaN` comparison bug. Several PHP quirks
were *deliberately* preserved for behavioral fidelity where they weren't outright bugs (e.g.
values outside `1-20` are zeroed rather than clamped; history is capped at 11 rolls, an
off-by-one from the original).

The per-die-type stats accordion (`#stats_tab`, one `<details>` per die size, `d4`-`d20`) and
the roll-history accordion (`#last_rolls`) originally used two different disclosure-arrow
styles — `#last_rolls` had a custom CSS arrow, `#stats_tab` still used the browser's native
`<details>` marker, which renders larger and inconsistently across die-size tables. Fixed by
wrapping each stats-accordion title in `<span class="roll_title">` (matching `#last_rolls`'s
existing markup) and extending `dice.css`'s marker-hiding/custom-arrow rules to cover both
selectors, so every collapsible section on the site now shares one arrow size and style.

## Page 2 — Yahtzee (`yahtzee/`)

A full Yahtzee implementation on top of the shared engine: scoresheet with live "potential
score" preview per category, hold/reroll, New Game confirmation, an undo scoped to the
window between committing a score and the next roll, a site-wide top-100 leaderboard
(`GameScore`: date + total), a pip-style toggle, and full `localStorage` game-state
persistence (schema-versioned so a stale saved blob from an older build falls back to a
clean new game instead of corrupting the UI).

Status: **feature-complete**. The leaderboard (`GameScore`: name + total + date) now records
a player name alongside the score, entered via a `#player_name` field above the scoresheet
and persisted client-side in `localStorage` between sessions (`decathlon/`'s existing
player-name pattern, copied verbatim into `yahtzee/`, including rendering leaderboard rows
via `textContent` rather than `innerHTML` so a player-supplied name can't be interpreted as
markup). A blank/whitespace-only name is stored as `"Anonymous"`. The leaderboard table was
reset to zero rows when this shipped, since the pre-existing rows had no name data. The
in-game prompt shown after a roll that must be scored ("You must now score these dice before
rolling again.") was extended with "Click the scoresheet in the row you want to score." to
make the required next action less ambiguous to new players.

## Page 3 — Decathlon (`decathlon/`)

The newest page: Reiner Knizia's *Decathlon*, ten dice mini-games ("events") played in
sequence, each with its own throw/freeze/scoring rules, feeding a scoresheet and a grand
total, with a top-100 leaderboard that (unlike Yahtzee) records a player **name** alongside
the score. Rules are sourced from `Decathlon_rules_scoresheet.pdf` in the repo root.

The app is a **harness + event-module** design: a small JS harness drives an ordered array
of event modules, each owning its own rules, dice count, scoring, and UI text; adding a new
event means writing one module and inserting it into the roster, not reworking the harness.
Four event *archetypes* emerged and were shared once a second instance proved the shape:

- **Freeze-parity** (`makeFreezeEvent`) — Discus (freeze even dice) and Javelin (freeze odd
  dice): throw all dice, must freeze ≥1 eligible die per throw, stop anytime or auto-end when
  all are frozen, invalid (scores 0) if no eligible die remains.
- **Two-phase** (`makeLongJump`, bespoke) — a run-up (freeze dice while staying under a
  budget) followed by a jump (freeze the run-up dice for real, maximizing value).
- **Height ladder** (`makeHeightLadderEvent`) — Pole Vault (variable 2-8 dice per jump, "no
  ones" rule, 20 rungs) and High Jump (fixed 5 dice, no "no ones" rule, 11 rungs): climb a
  ladder of heights, three back-to-back tries per height, skip only before the first try,
  score = best height mastered.
- **Sprint** (`makeSprintEvent`) — 100m/400m/1500m: 8 dice split into sets (2×4, 4×2, or
  8×1), each set thrown-then-frozen in turn from a shared pool of 5 rethrows; a rolled 6
  subtracts 6 from the total (the event score can go negative).
- **Shot Put** (bespoke) — 8 dice thrown one at a time; stop anytime or forced-end at 8;
  rolling a 1 invalidates the attempt.

All ten events are built and active. Every module that can end an attempt or the whole event
— whether by failure (a busted throw, three failed height tries) or by success (freezing the
last die, filling all 8 Shot Put dice, mastering the final height) — pauses via a shared
`pendingEnd` state before committing: the result is shown, Roll/Jump/Throw is disabled, and
only a "End Attempt"/"End Event" button is enabled, so the player always gets to see what
happened before the game moves on and wipes the board for the next attempt.

Status: **feature-complete** relative to the original plan; not yet committed to git. 56
tests pass. Rules text on the page now quotes `Decathlon_rules_scoresheet.pdf` verbatim
rather than a paraphrase.

## Conventions established across the project

- **Shared engine, one source of truth.** All dice rendering/rolling goes through
  `dice/engine.py`; no game reimplements roll logic.
- **`localStorage` persistence, schema-versioned.** Both Yahtzee and Decathlon persist
  in-progress game state client-side under a versioned key (`STATE_VERSION`), so a shape
  change to the saved state is always paired with a version bump — old blobs are discarded
  in favor of a clean new game rather than partially restoring and corrupting the UI.
- **Leaderboards are real `db.sqlite3` tables**, not session data — manual/headless testing
  that completes a full game writes a real row, which must be cleaned up afterward.
- **New event archetypes ship standalone first; a shared factory is only extracted once a
  second instance proves the shape is actually reusable** (this is why `makeFreezeEvent` and
  `makeHeightLadderEvent` exist, but `makeLongJump` and Shot Put remain bespoke).
- **JS event/module roster order must exactly mirror the scoresheet row order** defined in
  each app's `views.py`, not just be appended to — a past bug came from letting these drift.
- All verification for interactive multi-step flows (mid-game reload, forced failures,
  auto-completions) is done via headless Microsoft Edge (`msedge --headless=new`) driving
  seeded `localStorage` state across separate invocations sharing one `--user-data-dir`, plus
  the full Django test suite (`python manage.py test`).
- **Collapsible sections use native `<details>/<summary>`, never a JS-driven accordion**, with
  the native disclosure marker always hidden (`list-style: none` +
  `::-webkit-details-marker { display: none }`) in favor of one shared CSS `::before` arrow
  (`\25B6` closed / `\25BC` open) on a `.roll_title` span inside the `<summary>`. Any new
  collapsible section should reuse this exact pattern rather than the browser default, to
  avoid a repeat of the stats-vs-history arrow-size mismatch on the Dice Sim page.

## Status

All three pages are feature-complete relative to their original scope, with the current
leaderboard/UX polish pass (arrow consistency, Yahtzee player names, prompt wording) folded
into the sections above. There is no open in-flight work as of this writing; treat this
document as the baseline for whatever the next round of feedback asks for.
