Compare commits

...

14 Commits

Author SHA1 Message Date
brianfertig 858dc9535d Merge pull request 'Game-Polish' (#10) from Game-Polish into main
Reviewed-on: #10
2026-09-02 05:10:11 +00:00
Brian Fertig 52eaff8b96 Add word-steered board generation and level-scaled special tiles to Book
- Introduce BookworkSteering with context-aware letter placement: class
  steering, vowel-band control, and a real-word completion boost so boards
  stay playable and avoid dead/vowel-clumped states across refills.
- Make gold/diamond spawn chances configurable per level via a linear ramp
  (specialTiles in bookwork.json), wiring the values through clearAndRefill
  and refillSteered instead of hard-coded constants.
- Add verifyBookwormBoard harness comparing baseline vs steered boards on
  word availability, dead-board rate, and simulated session survival, and
  extend verifyBookwork with special-tile schedule checks.
2026-09-01 23:09:12 -06:00
Brian Fertig 44de2fb049 Rename Bookwork to Bookworm and add dictionary-steered grid generation
- Update display name in registry, scene title, README, and tooling references
- Load ENABLE word list at runtime; fall back to unsteered random on failure
- Introduce makeSteeredGrid/refillSteering helpers for class/vowel-aware letter placement
- Export LETTER_WEIGHTS from BookworkLogic for reuse by the steering module
- Add `steer` tuning block (wordBoostK, classThreshold/damp/boost, vowelBand) to bookwork.json
2026-09-01 22:57:07 -06:00
brianfertig e3d2fdee34 Merge pull request 'Game-Polish' (#9) from Game-Polish into main
Reviewed-on: #9
2026-09-02 02:51:15 +00:00
Brian Fertig 3c2cf462ed Polish Mahjong layout: reposition portraits and reference panel
- Rename card back themes (Cthulhu, Cyberpunk, Nautical, etc.) and update
  artwork assets to match
- Move player portraits just left of each seat's tiles so they read as
  belonging to that player without covering any tile
- Reposition the "Hands" reference button clear of seat 2's melds/bonus
  tiles, and hide seat 1's portrait video while the panel covers it
- Auto-close the open reference panel whenever claim or win/kong buttons
  appear so nothing clickable is hidden
2026-09-01 20:49:47 -06:00
Brian Fertig 93db9ef9dc Fix ghost button hover state to use full opacity and correct text color 2026-09-01 20:03:10 -06:00
brianfertig ae30c66a3a Merge pull request 'Polish' (#8) from Polish into main
Reviewed-on: #8
2026-09-01 04:46:09 +00:00
Brian Fertig 429102105b Persist last selected category across menu restarts
- Track the most recently chosen category in module scope so it survives scene.start() reusing the same instance.
- Fall back to that value when entering the menu without explicit category data, restoring the user's previous selection instead of defaulting.
2026-08-31 22:45:22 -06:00
Brian Fertig 1298648e97 Polish opponent select scene and restore category on return
- Replace flat title/subtitle pills with brass Plaque headers in OpponentSelectScene for visual consistency with the game menu
- Shift opponent scroll area down (OPP_SCROLL_TOP 155 -> 180, height 440 -> 415) to clear the subtitle plaque band
- Pass selected category back to GameMenu via scene.start data on Back so the active grid is restored instead of resetting to default
- Add GameMenuScene.init() to capture incoming category and reset stale instance props (_currentCategory, _gridAnim) since scene.start reuses the existing instance
- Guard fadeIn target list with .filter(Boolean) to skip any uncreated elements
2026-08-31 22:21:03 -06:00
Brian Fertig 74cd4df85b Polish title plates with rounded brass-trimmed plaque and size animation
- Replace flat black rectangles behind the game-menu and landing titles with a new Plaque component that draws a rounded, accent-bordered plate with a soft drop shadow and warm top highlight.
- Add letter-spacing and text shadow to the menu title so it reads as raised against the plate.
- When the tab label swaps (e.g. "Loading…" → "Choose a game"), tween the plaque to its new width via a size proxy that redraws each frame, and pop the title text with a Back.easeOut scale so the change feels intentional.
2026-08-31 20:55:29 -06:00
Brian Fertig cbec3b3a44 Polish game menu and landing scene transitions
- Add slide-in/out grid animations when switching categories in the game menu, replacing the instant rebuild
- Rename "Choose a game" to "Choose a Game Category" and update title dynamically on category change
- Fade in menu controls (tabs, hint, back button) on entry to complete the landing handoff
- Animate logo zoom-out and fade buttons out when transitioning from landing to game menu via goToGameMenu()
- Guard against double-taps and stale avatar loads during scene transitions
2026-08-31 19:53:22 -06:00
Brian Fertig f2108e6258 Remove default category selection in favor of a hint prompt
- Drop the `_lastCategory` module-level state and the logic that pre-selected a tab on scene entry.
- Show a "Select a category above to see its games" hint instead, destroyed once the user picks a category.
2026-08-31 19:39:09 -06:00
brianfertig 4ecfcaee50 Merge pull request 'jigsaw-ui-improvements' (#7) from jigsaw-ui-improvements into main
Reviewed-on: #7
2026-08-31 14:47:28 +00:00
brianfertig 0c59a292a3 Merge pull request 'Rework jigsaw UI with stitched mat playfield and two-column menu' (#6) from jigsaw-ui-improvements into main
Reviewed-on: #6
2026-08-31 05:30:00 +00:00
17 changed files with 798 additions and 56 deletions

View File

@ -88,7 +88,7 @@ menu categories:
| **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi | | **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi |
| **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle | | **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle |
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines | | **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble | | **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble |
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter | | **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control | | **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

View File

@ -1,5 +1,18 @@
{ {
"playerBaseHp": 100, "playerBaseHp": 100,
"steer": {
"wordBoostK": 1.0,
"classThreshold": 2,
"classDamp": 0.35,
"classBoost": 1.8,
"vowelBand": [0.30, 0.45]
},
"specialTiles": {
"startLevel": 1,
"endLevel": 10,
"start": { "gold": 0.10, "diamond": 0.06 },
"end": { "gold": 0.03, "diamond": 0.02 }
},
"milestones": [ "milestones": [
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" }, { "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
{ "afterLevel": 10, "maxHpBonus": 10 }, { "afterLevel": 10, "maxHpBonus": 10 },

View File

@ -1,13 +1,13 @@
{ {
"default": "back-0", "default": "back-0",
"cardBacks": [ "cardBacks": [
{ "id": "back-0", "name": "Card Back 1", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" }, { "id": "back-0", "name": "Cthulhu", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
{ "id": "back-1", "name": "Card Back 2", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" }, { "id": "back-1", "name": "Cyberpunk", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
{ "id": "back-2", "name": "Card Back 3", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" }, { "id": "back-2", "name": "Nautical", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
{ "id": "back-3", "name": "Card Back 4", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" }, { "id": "back-3", "name": "Steampunk", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
{ "id": "back-4", "name": "Card Back 5", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" }, { "id": "back-4", "name": "Jungle", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
{ "id": "back-5", "name": "Card Back 6", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" }, { "id": "back-5", "name": "Hi Tech", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
{ "id": "back-6", "name": "Card Back 7", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" }, { "id": "back-6", "name": "Fall Mountain", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
{ "id": "back-7", "name": "Card Back 8", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" } { "id": "back-7", "name": "Neon Miami", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
] ]
} }

View File

@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame:
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' }); registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' });
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 }); registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 }); registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 }); registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 }); registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 }); registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });

View File

@ -9,9 +9,10 @@ import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent, GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage, wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles, clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, computeMaxHp, isPotionUnlocked, specialTileChances,
} from './BookworkLogic.js'; } from './BookworkLogic.js';
import { getAttackDamage, getSpecialTile } from './BookworkAI.js'; import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
const CELL = 96; const CELL = 96;
const GRID_W = CELL * GRID_SIZE; const GRID_W = CELL * GRID_SIZE;
@ -39,7 +40,7 @@ export default class BookworkGame extends Phaser.Scene {
constructor() { super('BookworkGame'); } constructor() { super('BookworkGame'); }
init(data) { init(data) {
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' }; this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
this.config = { playerBaseHp: 100, milestones: [], levels: [] }; this.config = { playerBaseHp: 100, milestones: [], levels: [] };
this.bank = []; this.bank = [];
this.roster = []; this.roster = [];
@ -56,6 +57,9 @@ export default class BookworkGame extends Phaser.Scene {
this.bgTex = null; this.bgTex = null;
// Battle state // Battle state
this.grid = null; this.grid = null;
this.wordSet = null; // steering dictionary (ENABLE words 315); null → unsteered
this.steerOpts = {};
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
this.tileObjs = null; this.tileObjs = null;
this.selection = []; this.selection = [];
this.selGraphics = null; this.selGraphics = null;
@ -85,8 +89,16 @@ export default class BookworkGame extends Phaser.Scene {
const raw = this.cache.json.get('bookwork'); const raw = this.cache.json.get('bookwork');
if (raw) this.config = raw; if (raw) this.config = raw;
this.steerOpts = this.config.steer ?? {};
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level); this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
// Load the steering dictionary (same ENABLE list the word validator uses).
// Non-fatal: if it fails, boards fall back to plain weighted-random.
try {
const res = await fetch('data/wordlists/enable1.txt');
if (res.ok) this.wordSet = parseWordList(await res.text());
} catch (_) { this.wordSet = null; }
try { try {
const res = await fetch('data/opponents.json'); const res = await fetch('data/opponents.json');
const json = await res.json(); const json = await res.json();
@ -279,7 +291,7 @@ export default class BookworkGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'BOOKWORK', { const title = this.add.text(cx, 84, 'BOOKWORM', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', { const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
@ -514,6 +526,7 @@ export default class BookworkGame extends Phaser.Scene {
this.level = level; this.level = level;
this.levelDef = lv; this.levelDef = lv;
this.opponent = this.opponentFor(lv); this.opponent = this.opponentFor(lv);
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
this.clearLayer(); this.clearLayer();
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted); this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
@ -523,7 +536,7 @@ export default class BookworkGame extends Phaser.Scene {
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted); this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
this.potionUsed = false; this.potionUsed = false;
this.turnPhase = 'player'; this.turnPhase = 'player';
this.grid = makeGrid(); this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
this.selection = []; this.selection = [];
this.drawBattleUI(); this.drawBattleUI();
@ -832,7 +845,9 @@ export default class BookworkGame extends Phaser.Scene {
} }
// Refill grid // Refill grid
this.grid = clearAndRefill(this.grid, cells); this.grid = this.wordSet
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
await this.animateRefill(cells); await this.animateRefill(cells);
this.redrawAllTiles(); this.redrawAllTiles();

View File

@ -1,7 +1,7 @@
export const GRID_SIZE = 5; export const GRID_SIZE = 5;
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed) // Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
const LETTER_WEIGHTS = { export const LETTER_WEIGHTS = {
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2, A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2, L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
X:1, Y:3, Z:1, X:1, Y:3, Z:1,
@ -16,6 +16,29 @@ export function randomLetter(rng = Math.random) {
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)]; return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
} }
// Default spawn chances for multiplier tiles on refills (late-game floor).
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
// holds cfg.end. Returns { goldChance, diamondChance }.
// cfg = { startLevel: 1, endLevel: 10,
// start: { gold: 0.10, diamond: 0.06 },
// end: { gold: 0.03, diamond: 0.02 } }
export function specialTileChances(level, cfg = {}) {
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
const from = cfg.startLevel ?? 1;
const to = cfg.endLevel ?? from;
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
return {
goldChance: start.gold + (end.gold - start.gold) * t,
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
};
}
export function makeGrid(rng = Math.random) { export function makeGrid(rng = Math.random) {
const grid = []; const grid = [];
for (let r = 0; r < GRID_SIZE; r++) { for (let r = 0; r < GRID_SIZE; r++) {
@ -69,9 +92,12 @@ export function computeSelfDamage(cells, grid) {
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5; return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
} }
// Cascade tiles down in each column, fill top with new random tiles // Cascade tiles down in each column, fill top with new random tiles.
export function clearAndRefill(grid, usedCells, rng = Math.random) { // opts may override spawn chances: { goldChance, diamondChance }.
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`)); const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
const next = grid.map((row) => row.map((cell) => ({ ...cell }))); const next = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let c = 0; c < GRID_SIZE; c++) { for (let c = 0; c < GRID_SIZE; c++) {
@ -82,8 +108,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random) {
} }
// Fill remainder with new normal tiles // Fill remainder with new normal tiles
while (survive.length < GRID_SIZE) { while (survive.length < GRID_SIZE) {
const gold = rng() < 0.03; const gold = rng() < goldChance;
const diamond = !gold && rng() < 0.02; const diamond = !gold && rng() < diamondChance;
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal'; const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
survive.push({ letter: randomLetter(rng), type }); survive.push({ letter: randomLetter(rng), type });
} }

View File

@ -0,0 +1,204 @@
// BookworkSteering.js — context-aware letter placement for Bookworm
//
// The base engine (BookworkLogic.js) draws every letter independently from a
// weighted pool. That works, but it occasionally produces "dead" boards (no
// 3+ letter word findable) and vowel/consonant clumps, and those clumps
// persist through refills.
//
// This module layers a steering policy on the SAME pool: when a letter is
// chosen for a specific cell, each candidate's weight is adjusted by:
// 1. class steering — damp the class (vowel/consonant) already
// over-represented among the cell's filled neighbours
// 2. vowel band — keep the board-wide vowel share inside a target band
// 3. word boost — boost letters that complete real 3-letter words given
// the letters already placed around this cell
//
// Design notes:
// • Sampling, never argmax — boards stay varied and the injected-rng
// testability of BookworkLogic is preserved.
// • No full words are pre-placed. Steering only biases a single letter by
// what is already on the board, so the "word-friendly" property is
// re-established inductively after every refill — the board stays good
// as the game is played, not just at deal time.
// • With `wordSet` null every helper degrades to the plain weighted pool.
import { GRID_SIZE, LETTER_WEIGHTS, randomLetter, SPECIAL_TILE_CHANCES, getAdjacent, isAdjacent } from './BookworkLogic.js';
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
export const DEFAULT_STEER = {
wordBoostK: 1.0, // weight multiplier per word completed: w *= (1 + K * count)
classThreshold: 2, // neighbour class imbalance (|vowels - consonants|) that triggers steering
classDamp: 0.35, // multiplier applied to the over-represented class
classBoost: 1.8, // multiplier applied to the under-represented class
vowelBand: [0.30, 0.45], // target band for board-wide vowel share
bandMinFilled: 10, // only apply the vowel band once this many cells are filled
};
// Parse an ENABLE-style word list into a Set of A-Z words within [minLen, maxLen].
// 315 matches the /words/scrabble/validate dictionary (ENABLE, 215 letters)
// and the game's minimum playable word length.
export function parseWordList(text, minLen = 3, maxLen = 15) {
const set = new Set();
for (const raw of text.split('\n')) {
const w = raw.trim().toUpperCase();
if (w.length >= minLen && w.length <= maxLen && /^[A-Z]+$/.test(w)) set.add(w);
}
return set;
}
// Context-adjusted weight for every candidate letter at grid[r][c].
// `grid` may contain unfilled cells ({letter: null}) — they are ignored.
export function letterWeights(grid, r, c, wordSet = null, opts = {}) {
const o = { ...DEFAULT_STEER, ...opts };
// ── class steering: neighbour balance ───────────────────────────────
let v = 0, k = 0;
for (const nb of getAdjacent(r, c)) {
const l = grid[nb.r][nb.c]?.letter;
if (!l) continue;
if (VOWELS.has(l)) v++; else k++;
}
let vowelMult = 1, consonantMult = 1;
if (o.classSteer !== false) {
if (v >= k + o.classThreshold) { vowelMult = o.classDamp; consonantMult = o.classBoost; }
else if (k >= v + o.classThreshold) { consonantMult = o.classDamp; vowelMult = o.classBoost; }
}
// ── vowel band: board-wide share ────────────────────────────────────
if (o.vowelBand) {
let filled = 0, vTotal = 0;
for (let rr = 0; rr < GRID_SIZE; rr++) {
for (let cc = 0; cc < GRID_SIZE; cc++) {
if (rr === r && cc === c) continue;
const l = grid[rr][cc]?.letter;
if (!l) continue;
filled++;
if (VOWELS.has(l)) vTotal++;
}
}
if (filled >= o.bandMinFilled) {
const share = vTotal / filled;
if (share > o.vowelBand[1]) { vowelMult *= o.classDamp; consonantMult *= o.classBoost; }
else if (share < o.vowelBand[0]) { consonantMult *= o.classDamp; vowelMult *= o.classBoost; }
}
}
// ── word boost: 3-letter words completed through this cell ─────────
// For candidate L, count real words of the forms a-L-b (a,b filled
// neighbours), L-a-b and a-b-L (a,b filled neighbours, b adjacent a).
let counts = null;
if (wordSet && o.wordBoostK > 0) {
const N = [];
for (const nb of getAdjacent(r, c)) {
const l = grid[nb.r][nb.c]?.letter;
if (l) N.push({ l, r: nb.r, c: nb.c });
}
if (N.length >= 2) {
counts = new Array(26).fill(0);
for (let li = 0; li < 26; li++) {
const L = String.fromCharCode(65 + li);
if (!LETTER_WEIGHTS[L]) continue;
const seen = new Set();
for (const a of N) {
for (const b of N) {
if (a === b) continue;
const ab = isAdjacent(a, b);
const w1 = a.l + L + b.l;
if (wordSet.has(w1) && !seen.has(w1)) { seen.add(w1); counts[li]++; }
if (ab) {
const w2 = L + a.l + b.l;
if (wordSet.has(w2) && !seen.has(w2)) { seen.add(w2); counts[li]++; }
const w3 = a.l + b.l + L;
if (wordSet.has(w3) && !seen.has(w3)) { seen.add(w3); counts[li]++; }
}
}
}
}
}
}
// ── final weights ───────────────────────────────────────────────────
const weights = new Array(26).fill(0);
for (let li = 0; li < 26; li++) {
const L = String.fromCharCode(65 + li);
const base = LETTER_WEIGHTS[L];
if (!base) continue;
let w = base * (VOWELS.has(L) ? vowelMult : consonantMult);
if (counts) w *= 1 + o.wordBoostK * counts[li];
weights[li] = w;
}
return weights;
}
// Sample a letter from a 26-entry weight array (A..Z).
export function pickWeighted(weights, rng) {
let total = 0;
for (const w of weights) total += w;
if (total <= 0) return String.fromCharCode(65 + Math.floor(rng() * 26));
let x = rng() * total;
for (let i = 0; i < 26; i++) {
x -= weights[i];
if (x <= 0) return String.fromCharCode(65 + i);
}
return 'Z';
}
// Build a full 5×5 board with context steering (cells placed in random order,
// each steered by the already-placed neighbours).
export function makeSteeredGrid(rng = Math.random, wordSet = null, opts = {}) {
const grid = Array.from({ length: GRID_SIZE }, () =>
Array.from({ length: GRID_SIZE }, () => ({ letter: null, type: 'normal' })));
if (!wordSet) {
// Degrade to the plain weighted pool.
for (const row of grid) for (const cell of row) cell.letter = randomLetter(rng);
return grid;
}
const cells = [];
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) cells.push({ r, c });
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[cells[i], cells[j]] = [cells[j], cells[i]];
}
for (const { r, c } of cells) {
grid[r][c].letter = pickWeighted(letterWeights(grid, r, c, wordSet, opts), rng);
}
return grid;
}
// Same cascade semantics as BookworkLogic.clearAndRefill (survivors fall to
// the bottom of each column, fresh tiles drop into the top rows), except the
// fresh letters are chosen with context steering against the partially
// rebuilt board. Fresh tiles land ABOVE their column's survivors, so this
// also completes vertical word fragments the survivors left behind.
export function refillSteered(grid, usedCells, rng = Math.random, wordSet = null, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
// Pass 1: cascade survivors down each column; mark fresh slots as empty.
const newRows = new Array(GRID_SIZE).fill(0);
for (let c = 0; c < GRID_SIZE; c++) {
const survive = [];
for (let r = GRID_SIZE - 1; r >= 0; r--) {
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
}
newRows[c] = GRID_SIZE - survive.length;
for (let i = 0; i < survive.length; i++) next[GRID_SIZE - 1 - i][c] = survive[i];
for (let r = 0; r < newRows[c]; r++) next[r][c] = { letter: null, type: 'normal' };
}
// Pass 2: place fresh tiles — per column bottom-up, left→right across
// columns — so each new tile sees survivors + already-placed tiles.
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
for (let c = 0; c < GRID_SIZE; c++) {
for (let r = newRows[c] - 1; r >= 0; r--) {
const weights = wordSet ? letterWeights(next, r, c, wordSet, opts) : null;
const letter = weights ? pickWeighted(weights, rng) : randomLetter(rng);
const gold = rng() < goldChance;
const diamond = !gold && rng() < diamondChance;
next[r][c] = { letter, type: gold ? 'gold' : diamond ? 'diamond' : 'normal' };
}
}
return next;
}

View File

@ -81,7 +81,17 @@ export default class MahjongGame extends Phaser.Scene {
const { tracks, volume } = getGameSoundtrack(this); const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume); if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch { /* optional */ } } catch { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg); // Backdrop: the playfield chosen on the opponent-select screen (image
// texture, or the generated canvas texture for the "colored" playfields),
// falling back to its flat color and finally to the classic deep green.
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : FELT;
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color).setDepth(DEPTH.bg);
}
const names = [auth.user?.username ?? 'You']; const names = [auth.user?.username ?? 'You'];
const skills = { 0: 5 }; const skills = { 0: 5 };
@ -130,11 +140,16 @@ export default class MahjongGame extends Phaser.Scene {
} }
buildPortraits() { buildPortraits() {
// Each portrait sits just to the left of its own tiles, at the vertical
// centre of that area (rows/columns are all centred on their midpoints),
// so it reads as belonging to that player without ever covering a tile.
// Keep ~18-20px clearance: human hand left edge ≥ 391 (14 tiles), left
// column left edge 133, right column left edge 1836, top row ≥ 616.
const spots = [ const spots = [
{ x: 90, y: 870, r: 46 }, // human — bottom left { x: 325, y: 975, r: 46 }, // human — left of the bottom hand row
{ x: 1830, y: 170, r: 40 }, // seat 1 — right { x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column
{ x: 560, y: 95, r: 40 }, // seat 2 — top { x: 558, y: 70, r: 40 }, // seat 2 — left of the top row
{ x: 90, y: 310, r: 40 }, // seat 3 — left { x: 75, y: 580, r: 40 }, // seat 3 — left of the left column
]; ];
for (let seat = 0; seat < 4; seat++) { for (let seat = 0; seat < 4; seat++) {
const { x, y, r } = spots[seat]; const { x, y, r } = spots[seat];
@ -219,7 +234,10 @@ export default class MahjongGame extends Phaser.Scene {
}).setOrigin(0.5)); }).setOrigin(0.5));
this.refPanel = panel; this.refPanel = panel;
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), { // Parked just left of the reference panel's open position (1490) so it
// never ends up under it; y=210 clears seat 2's melds (y≤98) and bonus
// tiles (y≤157), which sit at x 1330+.
this.refBtn = new Button(this, 1399, 210, 'Hands', () => this.toggleReference(), {
width: 150, height: 52, fontSize: 22, variant: 'ghost', width: 150, height: 52, fontSize: 22, variant: 'ghost',
}).setDepth(DEPTH.ref + 1); }).setDepth(DEPTH.ref + 1);
} }
@ -233,6 +251,15 @@ export default class MahjongGame extends Phaser.Scene {
ease: 'Quad.Out', ease: 'Quad.Out',
}); });
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands'); this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
// Seat 1's portrait sits under the open panel. Its <video> is a DOM node
// that always renders above canvas content, so hide it while covered.
this.portraitCtrls[1]?.controller?.setVideoVisible?.(!this.refOpen);
}
// The claim row and the self win/kong buttons sit under the open panel's
// footprint — close it whenever they appear so nothing clickable is covered.
closeReferenceIfOpen() {
if (this.refOpen) this.toggleReference();
} }
// ── tile drawing ────────────────────────────────────────────────────────────── // ── tile drawing ──────────────────────────────────────────────────────────────
@ -382,7 +409,7 @@ export default class MahjongGame extends Phaser.Scene {
bx += 40; bx += 40;
} }
} else { // sides — vertical column of rotated backs } else { // sides — vertical column of rotated backs
const x = seat === 1 ? 1858 : 62; const x = seat === 1 ? 1858 : 155; // columns stay right of their portraits (seat 1: 1778+40, seat 3: 75+40)
let y = 580 - ((n - 1) * step) / 2; let y = 580 - ((n - 1) * step) / 2;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const back = this.makeTileBack(SM_W, SM_H); const back = this.makeTileBack(SM_W, SM_H);
@ -390,7 +417,7 @@ export default class MahjongGame extends Phaser.Scene {
this.dyn.add(back); this.dyn.add(back);
y += step; y += step;
} }
const mx = seat === 1 ? 1745 : 175; const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column
let my = 330; let my = 330;
for (const m of p.melds) { for (const m of p.melds) {
const row = this.makeMeldRow(m, 38, 52, false); const row = this.makeMeldRow(m, 38, 52, false);
@ -481,6 +508,7 @@ export default class MahjongGame extends Phaser.Scene {
btn.setVisible(!!spec); btn.setVisible(!!spec);
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`); if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
}); });
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
} }
// ── human input ─────────────────────────────────────────────────────────────── // ── human input ───────────────────────────────────────────────────────────────
@ -521,6 +549,7 @@ export default class MahjongGame extends Phaser.Scene {
return new Promise((resolve) => { return new Promise((resolve) => {
this.claimResolve = resolve; this.claimResolve = resolve;
this.chowOptions = opts.chows; this.chowOptions = opts.chows;
this.closeReferenceIfOpen();
const visible = []; const visible = [];
if (opts.win) visible.push(this.claimBtns.win); if (opts.win) visible.push(this.claimBtns.win);
if (opts.kong) visible.push(this.claimBtns.kong); if (opts.kong) visible.push(this.claimBtns.kong);

View File

@ -2,12 +2,11 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js'; import { api } from '../services/api.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
import { TutorialModal } from '../ui/TutorialModal.js'; import { TutorialModal } from '../ui/TutorialModal.js';
let _lastCategory = null;
const CATEGORIES = [ const CATEGORIES = [
{ key: 'tabletop', label: 'Tabletop' }, { key: 'tabletop', label: 'Tabletop' },
{ key: 'cards', label: 'Cards & Dice' }, { key: 'cards', label: 'Cards & Dice' },
@ -22,10 +21,29 @@ const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72; const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86; const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -145; const ICON_X_OFFSET = -145;
// How far (px) a grid travels off screen when switching categories.
const GRID_TRAVEL = GAME_WIDTH + 800;
// The most recently selected category. Module scope so it survives scene
// restarts (scene.start() reuses the instance) and also covers paths that
// start the menu without category data — the menu always returns to where
// the user last was.
let lastCategory = null;
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } constructor() { super('GameMenu'); }
init(data) {
// Category to restore on entry: the explicit one (coming back from the
// opponent picker) or the most recently selected one.
this._initialCategory = (data && data.category) || lastCategory;
// scene.start() reuses the existing scene instance, so instance props from
// the previous visit survive — reset them so this create() starts clean
// (otherwise showCategory()'s "same category" guard swallows the restore).
this._currentCategory = null;
this._gridAnim = null;
}
async create() { async create() {
playMenuMusic(); playMenuMusic();
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
@ -33,12 +51,18 @@ export default class GameMenuScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT); this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
addFullscreenButton(this); addFullscreenButton(this);
const titleText = this.add.text(cx, 60, 'Choose a game', { const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '64px', fontSize: '64px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7); titleText.setLetterSpacing(2);
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
this._titleText = titleText;
const plateW = titleText.width + 64;
const plateH = titleText.height + 28;
this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5);
this._titlePlateW = plateW;
const loadingText = this.add.text(cx, 540, 'Loading game list…', { const loadingText = this.add.text(cx, 540, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex, fontSize: '24px', color: COLORS.mutedHex,
@ -107,14 +131,49 @@ export default class GameMenuScene extends Phaser.Scene {
this._tabIcons[key] = icon; this._tabIcons[key] = icon;
}); });
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key; // No category is selected by default — the user picks one to see games.
this.showCategory(startKey); this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', {
fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
// Returning from the opponent picker: restore the category that was
// active when the game was chosen — its grid flies back in from the right.
if (this._initialCategory) {
this.showCategory(this._initialCategory);
}
// The landing scene zooms the logo out before starting us, so fade the
// menu controls in to complete the handoff.
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn].filter(Boolean);
for (const obj of fadeIn) obj.setAlpha(0);
this.tweens.add({
targets: fadeIn,
alpha: 1,
duration: 450,
ease: 'Power2.easeOut',
});
} }
showCategory(key) { showCategory(key) {
_lastCategory = key; if (key === this._currentCategory) return;
this._currentCategory = key;
lastCategory = key;
if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
if (this._titleText) {
this._titleText.setText('Choose a game');
const newW = this._titleText.width + 64;
const newH = this._titleText.height + 28;
if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) {
this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut');
this._titlePlateW = newW;
}
// Little pop so the label swap reads as intentional.
this._titleText.setScale(0.9);
this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' });
}
for (const [k, btn] of Object.entries(this._tabs)) { for (const [k, btn] of Object.entries(this._tabs)) {
btn.setActive(k === key); btn.setActive(k === key);
} }
@ -162,13 +221,41 @@ export default class GameMenuScene extends Phaser.Scene {
} }
} }
for (const obj of this._gameObjects) obj.destroy(); // Interrupt any grid animation still in flight (user changed their mind mid-flight).
this._gameObjects = []; if (this._gridAnim) {
for (const obj of this._gridAnim.objects) obj.destroy();
this._gridAnim = null;
this._gameObjects = [];
}
if (this._ptrMoveHandler) { if (this._ptrMoveHandler) {
this.input.off('pointermove', this._ptrMoveHandler, this); this.input.off('pointermove', this._ptrMoveHandler, this);
this._ptrMoveHandler = null; this._ptrMoveHandler = null;
} }
const oldObjects = this._gameObjects;
this._gameObjects = [];
if (oldObjects.length === 0) {
this.buildCategoryGrid(key);
return;
}
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
for (const obj of oldObjects) obj.disableInteractive();
this._gridAnim = { objects: oldObjects };
this.tweens.add({
targets: oldObjects,
x: `-=${GRID_TRAVEL}`,
duration: 380,
ease: 'Power2.easeIn',
onComplete: () => {
this._gridAnim = null;
for (const obj of oldObjects) obj.destroy();
this.buildCategoryGrid(key);
},
});
}
buildCategoryGrid(key) {
const games = this._gamesByCategory[key]; const games = this._gamesByCategory[key];
if (!games || games.length === 0) return; if (!games || games.length === 0) return;
@ -267,6 +354,18 @@ export default class GameMenuScene extends Phaser.Scene {
this._gameObjects.push(qg, qLabel); this._gameObjects.push(qg, qLabel);
} }
}); });
// Fly the grid in from off screen right.
const objs = this._gameObjects;
for (const obj of objs) obj.x += GRID_TRAVEL;
this._gridAnim = { objects: objs };
this.tweens.add({
targets: objs,
x: `-=${GRID_TRAVEL}`,
duration: 420,
ease: 'Power2.easeOut',
onComplete: () => { this._gridAnim = null; },
});
} }
openGame(game) { openGame(game) {

View File

@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js'; import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic } from '../ui/MenuMusic.js';
@ -16,6 +17,8 @@ export default class LandingScene extends Phaser.Scene {
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0); const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75); logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
this._logo = logo;
this._transitioning = false;
this.tweens.add({ this.tweens.add({
targets: logo, targets: logo,
@ -53,6 +56,7 @@ export default class LandingScene extends Phaser.Scene {
renderButtons() { renderButtons() {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const user = auth.user; const user = auth.user;
this._avatar = null;
const avatarR = 28; const avatarR = 28;
const avatarGap = 18; const avatarGap = 18;
@ -65,6 +69,8 @@ export default class LandingScene extends Phaser.Scene {
fontSize: '36px', fontSize: '36px',
color: COLORS.accentHex, color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this._welcomeText = welcomeText;
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
const hasAvatar = !!user?.avatarPath; const hasAvatar = !!user?.avatarPath;
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width; const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
@ -72,7 +78,8 @@ export default class LandingScene extends Phaser.Scene {
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx); welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45); this._welcomeBg = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
.setPosition(cx, y);
if (hasAvatar) { if (hasAvatar) {
const avatarCx = groupLeft + avatarR; const avatarCx = groupLeft + avatarR;
@ -86,11 +93,11 @@ export default class LandingScene extends Phaser.Scene {
this.load.start(); this.load.start();
}); });
} }
if (!this.scene.isActive('Landing')) return; if (this._transitioning || !this.scene.isActive('Landing')) return;
const maskG = this.make.graphics({ x: 0, y: 0, add: false }); const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff); maskG.fillStyle(0xffffff);
maskG.fillCircle(avatarCx, y, avatarR); maskG.fillCircle(avatarCx, y, avatarR);
this.add.image(avatarCx, y, key) this._avatar = this.add.image(avatarCx, y, key)
.setDisplaySize(avatarR * 2, avatarR * 2) .setDisplaySize(avatarR * 2, avatarR * 2)
.setMask(maskG.createGeometryMask()) .setMask(maskG.createGeometryMask())
.setDepth(1); .setDepth(1);
@ -98,7 +105,39 @@ export default class LandingScene extends Phaser.Scene {
})(); })();
} }
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 }); this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 }); this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
}
goToGameMenu() {
if (this._transitioning) return;
this._transitioning = true;
this.playBtn.disableInteractive();
this.profileBtn.disableInteractive();
// Fade the landing buttons out while the logo zooms past the camera.
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
if (this._avatar) fadeOut.push(this._avatar);
this.tweens.add({
targets: fadeOut,
alpha: 0,
duration: 350,
ease: 'Power2.easeOut',
});
if (!this._logo) { this.scene.start('GameMenu'); return; }
this.tweens.killTweensOf(this._logo);
this.tweens.add({
targets: this._logo,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scaleX: 2.4,
scaleY: 2.4,
alpha: 0,
duration: 700,
ease: 'Cubic.easeIn',
onComplete: () => this.scene.start('GameMenu'),
});
} }
} }

View File

@ -1,6 +1,7 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js'; import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
@ -17,8 +18,8 @@ const CARD_TILE_GAP = 14;
// Opponent grid scroll area // Opponent grid scroll area
const OPP_SCROLL_W = 1780; const OPP_SCROLL_W = 1780;
const OPP_SCROLL_H = 440; const OPP_SCROLL_H = 415;
const OPP_SCROLL_TOP = 155; // top edge of scroll area const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
export default class OpponentSelectScene extends Phaser.Scene { export default class OpponentSelectScene extends Phaser.Scene {
constructor() { super('OpponentSelect'); } constructor() { super('OpponentSelect'); }
@ -58,21 +59,32 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1); this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1); this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
const titleText = this.add.text(cx, 60, this.gameDef.name, { // Brass-plaque headers, same treatment as the game menu headings. The
// subtitle plaque sits in the band between the title plaque and the
// opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either.
const TITLE_Y = 55;
const PLATE_GAP = 10;
const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '52px', fontSize: '52px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72); titleText.setLetterSpacing(2);
this.children.moveBelow(titlePill, titleText); titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
this.children.moveBelow(titlePlate, titleText);
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', { const subtitleText = this.add.text(cx, 0, 'Choose your opponent', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '36px', fontSize: '36px',
color: COLORS.mutedHex, color: COLORS.mutedHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72); subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
this.children.moveBelow(subtitlePill, subtitleText); const subPlateH = subtitleText.height + 28;
const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2;
subtitleText.setY(subtitleY);
const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY);
this.children.moveBelow(subtitlePlate, subtitleText);
let opponents = []; let opponents = [];
try { try {
@ -87,7 +99,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
} }
const min = this.gameDef.minOpponents ?? 1; const min = this.gameDef.minOpponents ?? 1;
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), { new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), {
variant: 'ghost', variant: 'ghost',
width: 280, width: 280,
}); });

View File

@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
if (this._active) return; if (this._active) return;
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options; const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
if (v === 'ghost') { if (v === 'ghost') {
this._drawBg(bgh, 0.18); this._drawBg(bgh, 0.9);
this.text.setColor(COLORS.goldHex); this.text.setColor(thc);
} else { } else {
this._drawBg(bgh, 1); this._drawBg(bgh, 1);
this.text.setColor(thc); this.text.setColor(thc);

62
src/ui/Plaque.js Normal file
View File

@ -0,0 +1,62 @@
import * as Phaser from 'phaser';
import { COLORS } from '../config.js';
// A rounded, brass-trimmed plaque in the same design language as the
// category tabs. Use instead of a flat black rectangle behind title text.
export class Plaque extends Phaser.GameObjects.Graphics {
constructor(scene, width = 300, height = 90, options = {}) {
super(scene, false);
const {
fill = 0x241c10,
fillAlpha = 0.92,
border = COLORS.accent,
radius = 18,
} = options;
this._opts = { fill, fillAlpha, border, radius };
this._w = width;
this._h = height;
this.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65);
this.redraw(width, height);
scene.add.existing(this);
}
redraw(w, h) {
this._w = w;
this._h = h;
const { fill, fillAlpha, border, radius } = this._opts;
const r = Math.max(4, Math.min(radius, w / 2, h / 2));
this.clear();
this.fillStyle(fill, fillAlpha);
this.fillRoundedRect(-w / 2, -h / 2, w, h, r);
// Faint warm highlight just inside the top edge reads as a raised plate.
this.lineStyle(2, 0xfff6dd, 0.16);
this.lineBetween(-w / 2 + r + 4, -h / 2 + 4, w / 2 - r - 4, -h / 2 + 4);
this.lineStyle(2, border, 1);
this.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
}
// Animate the plate to a new size. Graphics in this Phaser build exposes
// no setScaleX/setScaleY (and scaling a redrawn shape is unreliable), so
// we tween a size proxy and redraw every frame instead.
animateSize(w, h, duration = 240, ease = 'Quad.easeOut', onComplete) {
if (this._sizeTween) this._sizeTween.stop();
const proxy = { w: this._w, h: this._h };
this._sizeTween = this.scene.tweens.add({
targets: proxy,
w,
h,
duration,
ease,
onUpdate: () => {
if (!this.scene) return; // destroyed mid-animation
this.redraw(proxy.w, proxy.h);
},
onComplete: () => {
this._sizeTween = null;
if (!this.scene) return; // destroyed mid-animation
this.redraw(w, h);
if (onComplete) onComplete();
},
});
}
}

View File

@ -1,13 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
// verifyBookwork.js — engine tests for Bookwork // verifyBookwork.js — engine tests for Bookworm
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent, GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage, wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles, clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
} from '../src/games/bookwork/BookworkLogic.js'; } from '../src/games/bookwork/BookworkLogic.js';
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js'; import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
let pass = 0, fail = 0; let pass = 0, fail = 0;
@ -92,6 +93,34 @@ const after = clearAndRefill(gridR, used);
ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE)); ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE));
ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type)); ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type));
// ── special tile schedule ────────────────────────────────────────────────────
console.log('\nspecial tile schedule');
const SCHED = {
startLevel: 1, endLevel: 10,
start: { gold: 0.10, diamond: 0.06 },
end: { gold: 0.03, diamond: 0.02 },
};
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
const at = (lv) => specialTileChances(lv, SCHED);
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
ok('clamps below startLevel', at(0).goldChance === 0.10);
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
const newCount = 3;
ok('goldChance:1 → every fresh tile is gold',
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
ok('diamondChance:1 → every fresh tile is diamond',
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
ok('refillSteered honors goldChance:1',
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
// ── dropSpecialTile ──────────────────────────────────────────────────────────── // ── dropSpecialTile ────────────────────────────────────────────────────────────
console.log('\ndropSpecialTile'); console.log('\ndropSpecialTile');
const gridS = makeGrid(() => 0.5); const gridS = makeGrid(() => 0.5);
@ -175,6 +204,10 @@ if (bwData) {
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp); ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
ok('playerBaseHp present', bwData.playerBaseHp > 0); ok('playerBaseHp present', bwData.playerBaseHp > 0);
ok('milestones array present', Array.isArray(bwData.milestones)); ok('milestones array present', Array.isArray(bwData.milestones));
const st = bwData.specialTiles;
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
} }
// ── Summary ─────────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,210 @@
#!/usr/bin/env node
// verifyBookwormBoard.js — Bookworm board-quality harness
//
// Compares the plain weighted-random board (baseline) against the steering
// layer (class steering + vowel band + word-completion boost) on:
// • static boards — words available, dead boards, vowel share
// • simulated sessions — play the longest word each turn, refill, repeat:
// words available per turn, dead turns (no word to play), turn survival
//
// Deterministic (seeded RNG). The dictionary is the same ENABLE list
// (315 letters) that /words/scrabble/validate accepts, so "available word"
// means "word the player can actually submit".
//
// Run: node tools/verifyBookwormBoard.js
import { readFileSync } from 'node:fs';
import {
GRID_SIZE, makeGrid, clearAndRefill, getAdjacent,
} from '../src/games/bookwork/BookworkLogic.js';
import {
makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER,
} from '../src/games/bookwork/BookworkSteering.js';
const N_BOARDS = 300;
const N_SESSIONS = 60;
const MAX_TURNS = 30;
let pass = 0, fail = 0;
function ok(label, cond) {
if (cond) { console.log(`${label}`); pass++; }
else { console.error(`${label}`); fail++; }
}
// ── deterministic rng ─────────────────────────────────────────────────────────
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── word finding (boggle DFS with prefix pruning; one path per word) ─────────
function buildPrefixSet(wordSet) {
const pre = new Set();
for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i));
return pre;
}
function findWords(grid, wordSet, prefixSet) {
const found = new Map(); // word -> [cells]
const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false));
const dfs = (r, c, word, cells) => {
const w = word + grid[r][c].letter;
const isWord = w.length >= 3 && wordSet.has(w);
if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]);
if (!prefixSet.has(w)) return;
if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return;
for (const { r: nr, c: nc } of getAdjacent(r, c)) {
if (visited[nr][nc]) continue;
visited[nr][nc] = true;
dfs(nr, nc, w, [...cells, { r, c }]);
visited[nr][nc] = false;
}
};
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) {
visited[r][c] = true;
dfs(r, c, '', []);
visited[r][c] = false;
}
return found;
}
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
const vowelShare = (grid) =>
grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE);
const quantile = (arr, q) => {
const s = [...arr].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
};
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length);
// ── setup ─────────────────────────────────────────────────────────────────────
console.log('Loading dictionary…');
const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8'));
const prefixSet = buildPrefixSet(wordSet);
console.log(` ${wordSet.size} words (315 letters), ${prefixSet.size} prefixes\n`);
const variants = {
baseline: {
make: (rng) => makeGrid(rng),
refill: (g, cells, rng) => clearAndRefill(g, cells, rng),
},
steered: {
make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER),
refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER),
},
};
// ── steering sanity ───────────────────────────────────────────────────────────
console.log('Steering sanity');
{
const rng = mulberry32(1);
const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER);
ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE));
ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter)));
ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q'));
const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER);
ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0);
ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0);
const letter = pickWeighted(w, mulberry32(7));
ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q');
const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER);
ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter)));
}
// ── static board quality ──────────────────────────────────────────────────────
console.log(`\nStatic boards (N=${N_BOARDS} each)`);
const staticStats = {};
for (const [name, v] of Object.entries(variants)) {
const wordCounts = [], shares = [];
for (let i = 0; i < N_BOARDS; i++) {
const g = v.make(mulberry32(1000 + i));
wordCounts.push(findWords(g, wordSet, prefixSet).size);
shares.push(vowelShare(g));
}
staticStats[name] = {
wordCounts,
dead: wordCounts.filter((n) => n === 0).length,
min: Math.min(...wordCounts),
p5: quantile(wordCounts, 0.05),
median: quantile(wordCounts, 0.5),
mean: mean(wordCounts),
max: Math.max(...wordCounts),
vowelMin: Math.min(...shares),
vowelMax: Math.max(...shares),
vowelMean: mean(shares),
};
}
const row = (label, f) =>
console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`);
console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14));
row('words/board (min)', (n) => staticStats[n].min);
row('words/board (p5)', (n) => staticStats[n].p5);
row('words/board (median)', (n) => staticStats[n].median);
row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1));
row('words/board (max)', (n) => staticStats[n].max);
row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`);
row('vowel share (minmax)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}${(100 * staticStats[n].vowelMax).toFixed(0)}%`);
row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`);
// ── simulated sessions ────────────────────────────────────────────────────────
console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`);
const sessionStats = {};
for (const [name, v] of Object.entries(variants)) {
const totals = [], wpt = [], dead = [], initial = [], longest = [];
for (let i = 0; i < N_SESSIONS; i++) {
const rng = mulberry32(90000 + i);
let grid = v.make(rng);
let deadTurn = false, total = 0;
for (let t = 0; t < MAX_TURNS; t++) {
const words = findWords(grid, wordSet, prefixSet);
if (t === 0) initial.push(words.size);
if (words.size === 0) { deadTurn = true; break; }
total += words.size;
wpt.push(words.size);
let best = null;
for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells };
longest.push(best.w.length);
grid = v.refill(grid, best.cells, rng);
}
totals.push(total);
if (deadTurn) dead.push(1); else dead.push(0);
}
sessionStats[name] = {
deadRate: mean(dead),
totalMedian: quantile(totals, 0.5),
totalMean: mean(totals),
wptMin: Math.min(...wpt),
wptP5: quantile(wpt, 0.05),
wptMedian: quantile(wpt, 0.5),
initialMedian: quantile(initial, 0.5),
longestMax: Math.max(...longest),
};
}
row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`);
row('initial words (median)', (n) => sessionStats[n].initialMedian);
row('words/turn (min)', (n) => sessionStats[n].wptMin);
row('words/turn (p5)', (n) => sessionStats[n].wptP5);
row('words/turn (median)', (n) => sessionStats[n].wptMedian);
row('total words (median)', (n) => sessionStats[n].totalMedian);
row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0));
row('longest word seen', (n) => sessionStats[n].longestMax);
// ── comparisons ───────────────────────────────────────────────────────────────
console.log('\nComparison');
ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median);
ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead);
ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5);
ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate);
ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian);
ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian);
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`);
process.exit(fail > 0 ? 1 : 0);