feat: add Bookwork word-battle game with 20-level campaign

Introduce Bookwork, a single-player word puzzle game where players
battle opponents by forming words from a 5×5 letter grid. Features:

- 20 levels with unique opponents, escalating difficulty, and special
  tile drops (fire, poison) via AI-driven attack system
- Grid mechanics: adjacent tile selection, word validation, damage
  calculation with gold/diamond multipliers, and fire tile self-damage
- Progression system with HP milestones, potion unlock, and server-
  persisted level completion
- Polished battle UI with animated word submission, fireworks, HP bars,
  portrait system, and victory/defeat overlays
- Full test suite (verifyBookwork.js) covering grid generation, damage
  formulas, refill logic, AI probabilities, and level data validation
This commit is contained in:
Brian Fertig 2026-06-20 15:51:55 -06:00
parent e4c12e91bd
commit 4e222a89bb
10 changed files with 1519 additions and 1 deletions

70
public/data/bookwork.json Normal file
View File

@ -0,0 +1,70 @@
{
"playerBaseHp": 100,
"milestones": [
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
{ "afterLevel": 10, "maxHpBonus": 10 },
{ "afterLevel": 15, "maxHpBonus": 10 }
],
"levels": [
{ "level": 1, "opponentId": "ethel", "skill": 1, "hp": 30, "attackMin": 3, "attackMax": 6,
"tagline": "A gentle warm-up over tea and vocabulary.",
"specialAttacks": [] },
{ "level": 2, "opponentId": "kona", "skill": 1, "hp": 33, "attackMin": 4, "attackMax": 7,
"tagline": "Woof! Surprisingly sharp with the alphabet.",
"specialAttacks": [] },
{ "level": 3, "opponentId": "bernie", "skill": 1, "hp": 36, "attackMin": 4, "attackMax": 8,
"tagline": "All fun and games until he finds the big words.",
"specialAttacks": [] },
{ "level": 4, "opponentId": "brad", "skill": 2, "hp": 39, "attackMin": 5, "attackMax": 9,
"tagline": "He came for the salmon and stayed for the Scrabble.",
"specialAttacks": [] },
{ "level": 5, "opponentId": "jerry", "skill": 2, "hp": 43, "attackMin": 5, "attackMax": 10,
"tagline": "Y'all ready for some real word-wranglin'?",
"specialAttacks": [] },
{ "level": 6, "opponentId": "jeff", "skill": 2, "hp": 47, "attackMin": 6, "attackMax": 11,
"tagline": "Reads slow. Hits fast. You've been warned.",
"specialAttacks": ["fire"] },
{ "level": 7, "opponentId": "mario", "skill": 3, "hp": 51, "attackMin": 6, "attackMax": 12,
"tagline": "Welcome to the labyrinth of letters!",
"specialAttacks": ["fire"] },
{ "level": 8, "opponentId": "juliet", "skill": 3, "hp": 55, "attackMin": 7, "attackMax": 13,
"tagline": "A warm summer day, a storm of scorched tiles.",
"specialAttacks": ["fire"] },
{ "level": 9, "opponentId": "michael", "skill": 3, "hp": 59, "attackMin": 7, "attackMax": 13,
"tagline": "Easy vibes, heavy damage, mon.",
"specialAttacks": ["fire"] },
{ "level": 10, "opponentId": "croc", "skill": 3, "hp": 63, "attackMin": 8, "attackMax": 14,
"tagline": "Waaaasup! Watch out for those flaming tiles!",
"specialAttacks": ["fire"] },
{ "level": 11, "opponentId": "gerome", "skill": 4, "hp": 67, "attackMin": 9, "attackMax": 15,
"tagline": "Extreme vocabulary or nothing!",
"specialAttacks": ["fire"] },
{ "level": 12, "opponentId": "beth", "skill": 4, "hp": 71, "attackMin": 9, "attackMax": 16,
"tagline": "Strangers 'round here leave with poisoned tongues.",
"specialAttacks": ["fire", "poison"] },
{ "level": 13, "opponentId": "steve", "skill": 4, "hp": 75, "attackMin": 10, "attackMax": 17,
"tagline": "Stupid Earth alphabet. Prepare to lose.",
"specialAttacks": ["fire", "poison"] },
{ "level": 14, "opponentId": "fireball", "skill": 4, "hp": 79, "attackMin": 11, "attackMax": 18,
"tagline": "No x-ray eyes. Just flawless fire tile drops.",
"specialAttacks": ["fire", "poison"] },
{ "level": 15, "opponentId": "natasha", "skill": 5, "hp": 83, "attackMin": 12, "attackMax": 18,
"tagline": "Your secrets vanish with your hit points.",
"specialAttacks": ["fire", "poison"] },
{ "level": 16, "opponentId": "victor", "skill": 5, "hp": 87, "attackMin": 12, "attackMax": 19,
"tagline": "Every poisoned tile calculated. Centuries ago.",
"specialAttacks": ["fire", "poison"] },
{ "level": 17, "opponentId": "balam", "skill": 5, "hp": 91, "attackMin": 13, "attackMax": 20,
"tagline": "Mystical powers meet the art of lexicon.",
"specialAttacks": ["fire", "poison"] },
{ "level": 18, "opponentId": "cybro", "skill": 5, "hp": 95, "attackMin": 13, "attackMax": 20,
"tagline": "The future has already corrupted your tiles.",
"specialAttacks": ["fire", "poison"] },
{ "level": 19, "opponentId": "zanthor", "skill": 5, "hp": 98, "attackMin": 14, "attackMax": 21,
"tagline": "Alacazam! Your hit points are doomed!",
"specialAttacks": ["fire", "poison"] },
{ "level": 20, "opponentId": "blackwind","skill": 5, "hp": 100, "attackMin": 14, "attackMax": 21,
"tagline": "The final word. Make it count, matey.",
"specialAttacks": ["fire", "poison"] }
]
}

View File

@ -0,0 +1,26 @@
// Probability of a special tile drop after an opponent attack, by skill level
const SPECIAL_PROB = {
1: { fire: 0, poison: 0 },
2: { fire: 0.15, poison: 0 },
3: { fire: 0.22, poison: 0 },
4: { fire: 0.28, poison: 0.08 },
5: { fire: 0.30, poison: 0.15 },
};
export function getAttackDamage(levelDef, rng = Math.random) {
const min = levelDef.attackMin ?? 3;
const max = levelDef.attackMax ?? 8;
return Math.round(min + rng() * (max - min));
}
// Returns 'fire' | 'poison' | null based on level's specialAttacks + skill probability
export function getSpecialTile(levelDef, rng = Math.random) {
const specials = levelDef.specialAttacks ?? [];
if (!specials.length) return null;
const skill = Math.min(5, Math.max(1, levelDef.skill ?? 1));
const prob = SPECIAL_PROB[skill];
const roll = rng();
if (specials.includes('poison') && roll < prob.poison) return 'poison';
if (specials.includes('fire') && roll < prob.poison + prob.fire) return 'fire';
return null;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,135 @@
export const GRID_SIZE = 5;
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
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,
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,
};
const LETTER_POOL = [];
for (const [l, w] of Object.entries(LETTER_WEIGHTS)) {
for (let i = 0; i < w; i++) LETTER_POOL.push(l);
}
export function randomLetter(rng = Math.random) {
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
}
export function makeGrid(rng = Math.random) {
const grid = [];
for (let r = 0; r < GRID_SIZE; r++) {
grid.push([]);
for (let c = 0; c < GRID_SIZE; c++) {
grid[r].push({ letter: randomLetter(rng), type: 'normal' });
}
}
return grid;
}
export function getAdjacent(r, c) {
const out = [];
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = r + dr, nc = c + dc;
if (nr >= 0 && nr < GRID_SIZE && nc >= 0 && nc < GRID_SIZE) {
out.push({ r: nr, c: nc });
}
}
}
return out;
}
export function isAdjacent(a, b) {
return Math.abs(a.r - b.r) <= 1 && Math.abs(a.c - b.c) <= 1 && !(a.r === b.r && a.c === b.c);
}
export function wordFromCells(grid, cells) {
return cells.map(({ r, c }) => grid[r][c].letter).join('');
}
// Damage by word length; gold = 1.5×, diamond = 2× (stacking)
const DMG_BY_LEN = [0, 0, 0, 1, 2, 4, 7, 11, 15];
export function computeDamage(cells, grid) {
const len = cells.length;
const base = len < DMG_BY_LEN.length ? DMG_BY_LEN[len] : 15 + (len - 8) * 3;
let mult = 1;
for (const { r, c } of cells) {
const t = grid[r][c].type;
if (t === 'gold') mult *= 1.5;
if (t === 'diamond') mult *= 2;
}
return Math.max(1, Math.round(base * mult));
}
// Fire tile self-damage: 5 per fire tile when word length < 5
export function computeSelfDamage(cells, grid) {
if (cells.length >= 5) return 0;
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
}
// Cascade tiles down in each column, fill top with new random tiles
export function clearAndRefill(grid, usedCells, rng = Math.random) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let c = 0; c < GRID_SIZE; c++) {
// Collect surviving tiles from bottom to top
const survive = [];
for (let r = GRID_SIZE - 1; r >= 0; r--) {
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
}
// Fill remainder with new normal tiles
while (survive.length < GRID_SIZE) {
const gold = rng() < 0.03;
const diamond = !gold && rng() < 0.02;
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
survive.push({ letter: randomLetter(rng), type });
}
// Assign back: survive[0] = bottom row
for (let r = GRID_SIZE - 1; r >= 0; r--) {
next[r][c] = survive[GRID_SIZE - 1 - r];
}
}
return next;
}
export function dropSpecialTile(grid, type, rng = Math.random) {
const normals = [];
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (grid[r][c].type === 'normal') normals.push({ r, c });
}
}
if (!normals.length) return grid;
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
const { r, c } = normals[Math.floor(rng() * normals.length)];
next[r][c] = { ...next[r][c], type };
return next;
}
export function countPoisonTiles(grid) {
let n = 0;
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (grid[r][c].type === 'poison') n++;
}
}
return n;
}
// Compute player max HP from config + levelsCompleted
export function computeMaxHp(config, levelsCompleted) {
const base = config.playerBaseHp ?? 100;
const bonus = (config.milestones ?? [])
.filter((m) => levelsCompleted >= m.afterLevel)
.reduce((sum, m) => sum + (m.maxHpBonus ?? 0), 0);
return base + bonus;
}
export function isPotionUnlocked(config, levelsCompleted) {
return (config.milestones ?? []).some(
(m) => m.unlock === 'potion' && levelsCompleted >= m.afterLevel,
);
}

View File

@ -82,6 +82,7 @@ import GinRummyGame from './games/ginrummy/GinRummyGame.js';
import RiskGame from './games/risk/RiskGame.js';
import GeniusSquareGame from './games/geniussquare/GeniusSquareGame.js';
import KataminoGame from './games/katamino/KataminoGame.js';
import BookworkGame from './games/bookwork/BookworkGame.js';
const config = {
type: Phaser.AUTO,
@ -177,6 +178,7 @@ const config = {
RiskGame,
GeniusSquareGame,
KataminoGame,
BookworkGame,
],
};

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -71,6 +71,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('zuma', '/data/zuma.json');
this.load.json('dotlink', '/data/dotlink.json');
this.load.json('katamino', '/data/katamino.json');
this.load.json('bookwork', '/data/bookwork.json');
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
@ -139,6 +140,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-gem-chain', '/assets/fx/gem-chain.mp3');
this.load.audio('sfx-gem-drop', '/assets/fx/gem-drop.mp3');
this.load.audio('sfx-gem-big-drop','/assets/fx/gem-big-drop.mp3');
this.load.audio('sfx-firework', '/assets/fx/firework.mp3');
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });

View File

@ -62,6 +62,7 @@ export const SFX = {
GEM_CHAIN: 'sfx-gem-chain',
GEM_DROP: 'sfx-gem-drop',
GEM_BIG_DROP: 'sfx-gem-big-drop',
FIREWORK: 'sfx-firework',
};
export function playSound(scene, key) {

View File

@ -98,3 +98,4 @@ 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 });
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: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });

182
verifyBookwork.js Normal file
View File

@ -0,0 +1,182 @@
#!/usr/bin/env node
// verifyBookwork.js — engine tests for Bookwork
import { readFileSync } from 'node:fs';
import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked,
} from './public/src/games/bookwork/BookworkLogic.js';
import { getAttackDamage, getSpecialTile } from './public/src/games/bookwork/BookworkAI.js';
let pass = 0, fail = 0;
function ok(label, cond) {
if (cond) { console.log(`${label}`); pass++; }
else { console.error(`${label}`); fail++; }
}
// ── Grid generation ──────────────────────────────────────────────────────────
console.log('\nGrid generation');
const g1 = makeGrid();
ok('grid is 5×5', g1.length === GRID_SIZE && g1.every((r) => r.length === GRID_SIZE));
ok('all cells have letter and type', g1.every((r) => r.every((c) => c.letter && c.type)));
const letters = g1.flat().map((c) => c.letter);
ok('all letters A-Z', letters.every((l) => /^[A-Z]$/.test(l)));
const vowels = letters.filter((l) => 'AEIOU'.includes(l));
ok('has at least 3 vowels', vowels.length >= 3);
// ── Adjacency ────────────────────────────────────────────────────────────────
console.log('\nAdjacency');
const corner = getAdjacent(0, 0);
ok('corner (0,0) has 3 neighbours', corner.length === 3);
const edge = getAdjacent(0, 2);
ok('edge (0,2) has 5 neighbours', edge.length === 5);
const center = getAdjacent(2, 2);
ok('center (2,2) has 8 neighbours', center.length === 8);
ok('isAdjacent diagonal', isAdjacent({ r: 0, c: 0 }, { r: 1, c: 1 }));
ok('isAdjacent same cell false', !isAdjacent({ r: 1, c: 1 }, { r: 1, c: 1 }));
ok('isAdjacent far false', !isAdjacent({ r: 0, c: 0 }, { r: 0, c: 2 }));
// ── Word from cells ───────────────────────────────────────────────────────────
console.log('\nWord from cells');
const g2 = makeGrid(() => 0.5);
g2[0][0].letter = 'W'; g2[0][1].letter = 'O'; g2[0][2].letter = 'R';
g2[1][2].letter = 'D'; // 'D' at (1,2) is adjacent to (0,2)
const cells1 = [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 2 }];
ok('wordFromCells produces WORD', wordFromCells(g2, cells1) === 'WORD');
// ── Damage ────────────────────────────────────────────────────────────────────
console.log('\nDamage');
const grid3 = makeGrid(() => 0.5);
grid3[0][0].letter = 'A'; grid3[0][1].letter = 'B'; grid3[0][2].letter = 'C';
const c3 = [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }];
ok('3-letter word = 1 dmg', computeDamage(c3, grid3) === 1);
const grid4 = makeGrid(() => 0.5);
for (let i = 0; i < 5; i++) { grid4[0][i].letter = String.fromCharCode(65 + i); }
const c4 = [0, 1, 2, 3, 4].map((c) => ({ r: 0, c }));
ok('5-letter word = 4 dmg', computeDamage(c4, grid4) === 4);
const gridG = makeGrid(() => 0.5);
gridG[0][0].letter = 'A'; gridG[0][1].letter = 'B'; gridG[0][2].letter = 'C';
gridG[0][0].type = 'gold';
const cG = [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }];
ok('3-letter gold word = round(1*1.5)=2', computeDamage(cG, gridG) === 2);
const gridD = makeGrid(() => 0.5);
for (let i = 0; i < 4; i++) gridD[0][i] = { letter: 'A', type: 'normal' };
gridD[0][0].type = 'diamond';
const cD = [0, 1, 2, 3].map((c) => ({ r: 0, c }));
ok('4-letter diamond word = round(2*2)=4', computeDamage(cD, gridD) === 4);
// ── Self-damage (fire tiles) ──────────────────────────────────────────────────
console.log('\nSelf-damage');
const gridF = makeGrid(() => 0.5);
gridF[0][0] = { letter: 'A', type: 'fire' };
gridF[0][1] = { letter: 'B', type: 'normal' };
gridF[0][2] = { letter: 'C', type: 'normal' };
const cF3 = [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }];
ok('fire tile in 3-letter word → 5 self-damage', computeSelfDamage(cF3, gridF) === 5);
const cF5 = [0, 1, 2, 3, 4].map((c) => ({ r: 0, c }));
gridF[0][3] = { letter: 'D', type: 'normal' };
gridF[0][4] = { letter: 'E', type: 'normal' };
ok('fire tile in 5-letter word → 0 self-damage', computeSelfDamage(cF5, gridF) === 0);
// ── clearAndRefill ─────────────────────────────────────────────────────────────
console.log('\nclearAndRefill');
const gridR = makeGrid();
const used = [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }];
const after = clearAndRefill(gridR, used);
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));
// ── dropSpecialTile ────────────────────────────────────────────────────────────
console.log('\ndropSpecialTile');
const gridS = makeGrid(() => 0.5);
const gridFire = dropSpecialTile(gridS, 'fire');
const fireCount = gridFire.flat().filter((c) => c.type === 'fire').length;
ok('exactly 1 fire tile dropped', fireCount === 1);
const gridPoison = dropSpecialTile(gridS, 'poison');
const poisonCount2 = gridPoison.flat().filter((c) => c.type === 'poison').length;
ok('exactly 1 poison tile dropped', poisonCount2 === 1);
// ── countPoisonTiles ──────────────────────────────────────────────────────────
console.log('\ncountPoisonTiles');
const gridP = makeGrid(() => 0.5);
ok('no poison tiles initially', countPoisonTiles(gridP) === 0);
gridP[0][0].type = 'poison';
gridP[2][2].type = 'poison';
ok('counts 2 poison tiles', countPoisonTiles(gridP) === 2);
// ── computeMaxHp ──────────────────────────────────────────────────────────────
console.log('\ncomputeMaxHp / isPotionUnlocked');
const cfg = {
playerBaseHp: 100,
milestones: [
{ afterLevel: 5, maxHpBonus: 10, unlock: 'potion' },
{ afterLevel: 10, maxHpBonus: 10 },
{ afterLevel: 15, maxHpBonus: 10 },
],
};
ok('max HP = 100 at level 0', computeMaxHp(cfg, 0) === 100);
ok('max HP = 110 at level 5', computeMaxHp(cfg, 5) === 110);
ok('max HP = 120 at level 10', computeMaxHp(cfg, 10) === 120);
ok('max HP = 130 at level 15', computeMaxHp(cfg, 15) === 130);
ok('potion locked before level 5', !isPotionUnlocked(cfg, 4));
ok('potion unlocked at level 5', isPotionUnlocked(cfg, 5));
// ── AI attack ─────────────────────────────────────────────────────────────────
console.log('\nAI');
const lv1 = { attackMin: 3, attackMax: 6, skill: 1, specialAttacks: [] };
const lv14 = { attackMin: 11, attackMax: 18, skill: 4, specialAttacks: ['fire', 'poison'] };
const atks = Array.from({ length: 200 }, () => getAttackDamage(lv1));
ok('level 1 attacks in [3,6]', atks.every((a) => a >= 3 && a <= 6));
const atks14 = Array.from({ length: 200 }, () => getAttackDamage(lv14));
ok('level 14 attacks in [11,18]', atks14.every((a) => a >= 11 && a <= 18));
const specials = Array.from({ length: 400 }, () => getSpecialTile(lv14));
ok('level 14 special tiles are fire/poison/null', specials.every((s) => ['fire', 'poison', null].includes(s)));
ok('skill-1 never drops specials', Array.from({ length: 200 }, () => getSpecialTile(lv1)).every((s) => s === null));
// ── bookwork.json validation ───────────────────────────────────────────────────
console.log('\nbookwork.json');
let bwData;
try {
bwData = JSON.parse(readFileSync('./public/data/bookwork.json', 'utf8'));
} catch (e) {
console.error(' ✗ Could not read bookwork.json:', e.message);
fail++;
}
if (bwData) {
ok('has 20 levels', bwData.levels?.length === 20);
ok('levels numbered 1-20', bwData.levels.every((l, i) => l.level === i + 1));
let oppData;
try {
oppData = JSON.parse(readFileSync('./public/data/opponents.json', 'utf8'));
} catch (_) { oppData = null; }
if (oppData) {
const ids = new Set(oppData.opponents.map((o) => o.id));
const allValid = bwData.levels.every((l) => ids.has(l.opponentId));
ok('all opponentIds valid', allValid);
if (!allValid) {
for (const l of bwData.levels) {
if (!ids.has(l.opponentId)) console.error(` missing: ${l.opponentId}`);
}
}
}
ok('all levels have hp/attackMin/attackMax', bwData.levels.every((l) => l.hp > 0 && l.attackMin >= 0 && l.attackMax > l.attackMin));
ok('all specialAttacks are arrays', bwData.levels.every((l) => Array.isArray(l.specialAttacks)));
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
ok('playerBaseHp present', bwData.playerBaseHp > 0);
ok('milestones array present', Array.isArray(bwData.milestones));
}
// ── Summary ───────────────────────────────────────────────────────────────────
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`);
if (fail > 0) process.exit(1);