#!/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 '../src/games/bookwork/BookworkLogic.js'; import { getAttackDamage, getSpecialTile } from '../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('./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('./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);