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:
parent
e4c12e91bd
commit
4e222a89bb
|
|
@ -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"] }
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue