fertig-classic-games/tools/verifyBookwork.js

216 lines
12 KiB
JavaScript
Raw 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 Bookworm
import { readFileSync } from 'node:fs';
import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
} from '../src/games/bookwork/BookworkLogic.js';
import { refillSteered } from '../src/games/bookwork/BookworkSteering.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));
// ── special tile schedule ────────────────────────────────────────────────────
console.log('\nspecial tile schedule');
const SCHED = {
startLevel: 1, endLevel: 10,
start: { gold: 0.10, diamond: 0.06 },
end: { gold: 0.03, diamond: 0.02 },
};
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
const at = (lv) => specialTileChances(lv, SCHED);
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
ok('clamps below startLevel', at(0).goldChance === 0.10);
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
const newCount = 3;
ok('goldChance:1 → every fresh tile is gold',
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
ok('diamondChance:1 → every fresh tile is diamond',
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
ok('refillSteered honors goldChance:1',
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
// ── 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));
const st = bwData.specialTiles;
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
}
// ── Summary ───────────────────────────────────────────────────────────────────
console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`);
if (fail > 0) process.exit(1);