diff --git a/.cursor/rules/create-game.mdc b/.cursor/rules/create-game.mdc new file mode 100644 index 00000000..3981477f --- /dev/null +++ b/.cursor/rules/create-game.mdc @@ -0,0 +1,154 @@ +--- +description: Add a new Pokémon Showdown room game under src/ps/games (any type: grid, single-player, N-player, forms, hidden info) +globs: + - src/ps/games/** + - scripts/debug-games/** +alwaysApply: false +--- + +# Creating a PS room game + +Do not invent a command file. `src/ps/commands/games/core.tsx` builds `/` from `Games`. Copy the **closest existing game**, then strip what you don't need. + +| Kind | Copy | Why | +|------|------|-----| +| 2-player grid, click a cell | `othello` | simplest `play i-j` | +| Select piece, then destination | `chess` / `linesofaction` | `action()` parses `select` / `move` from ctx | +| Drop-in-column / one param | `connectfour` | ctx is a single index | +| Dice / "just go" | `snakesladders` | `${this.msg} !` with empty ctx | +| N-player, no named sides | `azul` / `splendor` | `minSize`/`maxSize`, `autostart: false` | +| Single-player puzzle | `lightsout` | `players: 'single'`, `abbr` required | +| Guess / typed input | `mastermind` | `Form` + `{field}` placeholders | +| Hidden boards / setup phase | `battleship` | per-player state, `update(player.id)` | +| Mods or themes | `scrabble` / `chess` | `meta.mods` or `meta.themes` | + +## Naming (must all agree) + +- Folder name = `GamesList` **string value**. +- `meta.name` with spaces stripped = exported class (`Lights Out` → `LightsOut`). +- Id is concatenated lowercase (`linesofaction`, `snakesladders`). + +## Files + +Required: `meta.ts`, `types.ts`, `index.ts` (`export { meta }`), `render.tsx`. +Add `logs.ts` if you record moves; `constants.ts` / `mods.ts` only if the template has them. + +Register in two places only: + +1. `GamesList` in `src/ps/games/types.ts` +2. import + `Games` map in `src/ps/games/index.ts` + +Do **not** add UGO spotlight (`BOARD_GAMES_STRUCHNI_ORDER`) or a custom replay page unless asked. Generic replay is `/api//` + `src/web/react/pages/[game].tsx`. + +## Meta knobs + +- `players: 'many'` (default table games) or `'single'` (needs `abbr`; id is `#-`). +- `turns`: named sides (`B`/`W`). Omit for free-for-all — turns become player ids. +- `minSize` / `maxSize` when not exactly 2. +- `autostart: true` starts when full; `false` needs staff `,start`. +- `timer` / `pokeTimer`: `fromHumanTime(...)`. Single-player often has none. +- `htp`: `{ goal, sections }` — required. +- `ugo`: copy a similar game or `null`. Don't invent new point tables. + +## Button / form routing (easy to get wrong) + +`this.msg` (`@#GAMEID`) targets **this instance**. `this.simpleMsg` (`@ROOM `) is for watch / create / audience (typical single-player chrome). + +Spoof: `@#ID ` → ` , `. + +Only **`play`** (aliases `p`, `!`) calls `game.action(user, ctx, false)`. Everything after `play`/`!` is `ctx`. Other subcommands (`join`, `watch`, `audience`, `create`) are core commands — do not reuse those words as ctx verbs by putting them next to `this.msg` without `play`/`!`. + +```tsx +// ✅ reaches action(); ctx is whatever follows play/! +value={`${this.msg} play ${i}-${j}`} +value={`${this.msg} ! select ${i}-${j}`} +value={`${this.msg} !`} // empty ctx (roll) +
// ctx = "x QZ" + +// ❌ parsed as a game subcommand; never hits action() +value={`${this.msg} select ${i}-${j}`} +value={`${this.msg} move ${from}-${to}`} +``` + +`select` / `move` / `set` are **ctx verbs** parsed inside `action()`, not command names. Use `Button` / `Form` from `@/utils/components/ps`. Grid helper: `Table` from `@/ps/games/render` — skip it if the UI isn't a grid. + +## UI (`render.tsx`) + +PS room HTML sits in a dark `#page` shell (`color: white`, `background: #000a`). Match existing grid games — copy Othello/Chess patterns, don't invent a palette. + +**Chrome defaults** — headers, trays, legend, pickers: +- `Button`: `background: none`, `color: inherit`. Dim headers: `color: gray`. +- Borders only where they carry meaning. No `opacity` on text. +- Do not copy another game's color constant block (`S`/`SS`/`B` from Azul, etc.) into every `render.tsx`. + +**Grid boards** — `Table` from `@/ps/games/render`; override default `margin: 20` when the board is the focal element. `labels={null}` when row/col headers don't help (large boards). + +**One board, one square** — the cell grid is the board. No nested wrapper with its own border, padding, or rounded frame around the table. Scroll container: `overflow: auto` only. + +**Locked board theme** — sparingly, on the central board only when fixed colors improve readability: +- Theme values local inside the board renderer (not file-level aliases). +- Empty cells + subtle grid lines = low contrast (visual guides). +- Interactive elements on the board (valid-move targets, clickable affordances) = higher contrast than the grid. +- Trays, orientation pickers, and legend stay on inherited `#page` theme — not the locked board palette. + +**STYLES LIKE FLEX AND GRID DO NOT WORK ON PS. DO NOT TRY TO USE FLEXBOXES IN ANY WAY OR FORM.** + +**Valid-move hints** — dashed circles / outlines on target cells (Othello pattern), not filled ghost tiles over the board. + +**Option lists** (piece trays, orientation rows) — no bordered box around every item. Unselected: `border: none`. Selected: `outline` or similar, not a padded gray frame. + +**Multi-cell previews** — layout from actual cell coordinates (offset by min row/col so shapes don't clip). Markers (stars, labels) render inside the cell they refer to, not absolutely positioned outside the shape. + +## HTML page size (hard cap + target) + +Game `render()` output becomes a PS **page** via `user.pageHTML` (`src/ps/games/game.ts`). PS truncates or breaks pages above the cap. + +| | Value | Source | +|---|---|---| +| Hard cap | **99,500** chars | `MAX_PAGE_HTML_LENGTH` in `src/ps/constants.ts` | +| **Target (must stay under)** | **~80% → ~79,600** | headroom for worst-case UI states | +| Serialization | `jsxToHTML` → `renderToStaticMarkup`, no minification yet | `src/utils/jsxToHTML.ts` | + +**Why 80%?** Inline styles repeat per element. A typical mid-game view (board + tray + valid-move buttons + orientation picker) is much heavier than an empty board. Blokus 20×20 hit **114k** before trim — board + full piece tray alone was **111k**. + +**Measure before you ship UI:** + +1. Point `src/ps/games/test.tsx` at your `render` with a **stress mock** — not an empty board. Include max realistic tray size, selected piece, orientation row, and a generous `validAnchors` / valid-move list. +2. `npm run debug` — watcher logs `HTML N / 99500 (pct%)` (`src/ps/games/debug.ts`). Debug page shows `HTML {HTML_LENGTH} / {HTML_LIMIT}` (`scripts/debug-games/templates/page.html`). +3. If over **80%**, shrink HTML before polishing pixels. Re-measure after every structural change. + +**Shrink HTML without ugly UI** (prefer these over shrinking the board or removing affordances): + +- **One element per grid cell** — content directly in ``; no inner wrapper `div` for layout/flex when `margin: auto` or `text-align` on the cell suffices. +- **Short color tokens** — `#2e3848` beats `rgba(232, 236, 237, 0.18)`; every cell repeats border/background strings. +- **Local style objects** inside the board renderer (`td`, `dot`, …) — shared keys, not duplicated long literals in JSX. +- **Skip decorative CSS** on cells — no `box-sizing`, `display:flex`, `overflow:hidden` unless the cell actually needs it. +- **Option lists multiply cost** — each tray/orientation `Button` + mini-preview is hundreds of chars; keep preview markup lean (see trimmed `PieceMini` in `src/ps/games/blokus/render.tsx`). +- **Valid-move buttons are expensive** — each anchor is a ` +
HTML {HTML_LENGTH} / {HTML_LIMIT}
{HTML}