183 lines
9.7 KiB
JavaScript
183 lines
9.7 KiB
JavaScript
#!/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);
|