fertig-classic-games/tools/verifyBookwork.js

183 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);