diff --git a/public/data/bookwork.json b/public/data/bookwork.json new file mode 100644 index 0000000..b9eb391 --- /dev/null +++ b/public/data/bookwork.json @@ -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"] } + ] +} diff --git a/public/src/games/bookwork/BookworkAI.js b/public/src/games/bookwork/BookworkAI.js new file mode 100644 index 0000000..6cd3445 --- /dev/null +++ b/public/src/games/bookwork/BookworkAI.js @@ -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; +} diff --git a/public/src/games/bookwork/BookworkGame.js b/public/src/games/bookwork/BookworkGame.js new file mode 100644 index 0000000..39abf9d --- /dev/null +++ b/public/src/games/bookwork/BookworkGame.js @@ -0,0 +1,1099 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; +import { + GRID_SIZE, makeGrid, getAdjacent, isAdjacent, + wordFromCells, computeDamage, computeSelfDamage, + clearAndRefill, dropSpecialTile, countPoisonTiles, + computeMaxHp, isPotionUnlocked, +} from './BookworkLogic.js'; +import { getAttackDamage, getSpecialTile } from './BookworkAI.js'; + +const CELL = 96; +const GRID_W = CELL * GRID_SIZE; +const GRID_X = (GAME_WIDTH - GRID_W) / 2; // 720 +const GRID_Y = 210; + +const PLAYER_CX = 290; +const OPP_CX = GAME_WIDTH - 290; // 1630 + +const TILE_COLS = { + normal: { bg: 0xf0eada, border: 0xbfb59e, letter: '#2a1a0a', shine: 0xffffff }, + gold: { bg: 0xd4af37, border: 0xf0d060, letter: '#1a0e00', shine: 0xfffff0 }, + diamond: { bg: 0x1d6fa8, border: 0x55aadd, letter: '#e8f4ff', shine: 0xaaddff }, + fire: { bg: 0xb83222, border: 0xe05030, letter: '#fff0e8', shine: 0xff9977 }, + poison: { bg: 0x5d2580, border: 0x9944c8, letter: '#f0e8ff', shine: 0xcc88ff }, +}; + +const D = { + bg: -2, board: 0, tiles: 5, selLine: 8, + selHighlight: 10, letters: 15, ui: 20, + fx: 30, overlay: 60, overlayUI: 62, +}; + +export default class BookworkGame extends Phaser.Scene { + constructor() { super('BookworkGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' }; + this.config = { playerBaseHp: 100, milestones: [], levels: [] }; + this.bank = []; + this.roster = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.view = 'select'; + this.portraits = []; + this.match = null; + // Battle state + this.grid = null; + this.tileObjs = null; + this.selection = []; + this.selGraphics = null; + this.playerHp = 100; + this.playerMaxHp = 100; + this.oppHp = 0; + this.oppMaxHp = 0; + this.turnPhase = 'idle'; + this.potionUsed = false; + this.potionUnlocked = false; + this.playerHpText = null; + this.oppHpText = null; + this.playerHpBar = null; + this.oppHpBar = null; + this.wordText = null; + this.statusText = null; + this.submitBtn = null; + this.potionBtn = null; + this.poisonWarning = null; + } + + async create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) {} + + const raw = this.cache.json.get('bookwork'); + if (raw) this.config = raw; + this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level); + + try { + const res = await fetch('/data/opponents.json'); + const json = await res.json(); + this.roster = json.opponents ?? []; + } catch (_) { this.roster = []; } + + try { + const res = await api.get('/puzzles/bookwork/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + this.makeTextures(); + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0f0a05).setDepth(D.bg); + this.layer = this.add.container(0, 0); + this.showLevelSelect(); + } + + opponentFor(levelDef) { + const opp = this.roster.find((o) => o.id === levelDef.opponentId); + if (opp) return opp; + return { id: levelDef.opponentId, spriteIndex: 0, name: levelDef.opponentId, bio: '', speech: {} }; + } + + // ── Textures ──────────────────────────────────────────────────────────────── + + makeTextures() { + if (this.textures.exists('bw-tile-normal')) return; + + for (const [type, col] of Object.entries(TILE_COLS)) { + const g = this.make.graphics({ add: false }); + g.fillStyle(col.bg, 1); + g.fillRoundedRect(3, 3, CELL - 6, CELL - 6, 12); + g.fillStyle(col.shine, 0.25); + g.fillRoundedRect(7, 5, CELL - 14, 16, 6); + g.lineStyle(2, col.border, 1); + g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 12); + g.generateTexture(`bw-tile-${type}`, CELL, CELL); + g.destroy(); + } + + // Selection highlight overlay + const sel = this.make.graphics({ add: false }); + sel.lineStyle(4, 0xfff5a0, 1); + sel.strokeRoundedRect(2, 2, CELL - 4, CELL - 4, 12); + sel.fillStyle(0xfff5a0, 0.15); + sel.fillRoundedRect(2, 2, CELL - 4, CELL - 4, 12); + sel.generateTexture('bw-sel', CELL, CELL); + sel.destroy(); + + // HP bar backgrounds + const hpBg = this.make.graphics({ add: false }); + hpBg.fillStyle(0x1a1208, 1); + hpBg.fillRoundedRect(0, 0, 220, 22, 6); + hpBg.generateTexture('bw-hpbg', 220, 22); + hpBg.destroy(); + + // Firework spark particle + const fw = this.make.graphics({ add: false }); + fw.fillStyle(0xffffff, 1); + fw.fillCircle(5, 5, 5); + fw.generateTexture('bw-fw-spark', 10, 10); + fw.destroy(); + } + + // ── View management ───────────────────────────────────────────────────────── + + clearLayer() { + for (const p of this.portraits) { try { p.destroy(); } catch (_) {} } + this.portraits = []; + this.turnPhase = 'idle'; + this.selection = []; + this.grid = null; + this.tileObjs = null; + this.selGraphics = null; + this.layer.removeAll(true); + } + + // ── Level select ───────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.match = null; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 84, 'BOOKWORK', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + if (!this.bank.length) { + const msg = this.add.text(cx, 520, 'No levels found in /data/bookwork.json', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, + }).setOrigin(0.5); + const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' }); + this.layer.add([msg, back]); + return; + } + + const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); + const prog = this.add.text(cx, 180, `Defeated ${this.levelsCompleted} / ${this.bank.length}`, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add(prog); + + const COLS = 10; + const SIZE = 128; + const GAP = 16; + const gridW = COLS * SIZE + (COLS - 1) * GAP; + const left = cx - gridW / 2 + SIZE / 2; + const top = 305; + + this.bank.forEach((lv, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = left + col * (SIZE + GAP); + const y = top + row * (SIZE + GAP + 36); + const level = lv.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + const opp = this.opponentFor(lv); + + const fill = cleared ? 0x1f4a2a : playable ? 0x1e3040 : 0x16202b; + const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744; + const tile = this.add.rectangle(x, y, SIZE, SIZE + 28, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, y - SIZE / 2 + 22, String(level), { + fontFamily: 'Righteous', fontSize: '26px', + color: playable || cleared ? COLORS.textHex : '#54606b', + }).setOrigin(0.5); + const objs = [tile, num]; + + if (this.textures.exists('opponents')) { + const face = this.add.image(x, y + 6, 'opponents', opp.spriteIndex ?? 0).setDisplaySize(76, 76); + if (!playable && !cleared) { face.setTint(0x333a44); face.setAlpha(0.7); } + objs.push(face); + } + const tag = this.add.text(x, y + SIZE / 2 + 2, cleared ? `✓ ${opp.name}` : playable ? opp.name : 'locked', { + fontFamily: '"Julius Sans One"', fontSize: '15px', + color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b', + }).setOrigin(0.5); + objs.push(tag); + this.layer.add(objs); + + if (playable) { + tile.setInteractive({ useHandCursor: true }); + tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1)); + tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1)); + tile.on('pointerup', () => { playSound(this, SFX.UI_PICK); this.showIntro(level); }); + } + }); + + const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel), + { width: 280, height: 58, fontSize: 24 }); + const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 180, height: 58, fontSize: 24 }); + const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(), + { variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex }); + this.layer.add([resume, back, reset]); + + if (!this.canPersist) { + const note = this.add.text(cx, GAME_HEIGHT - 28, 'Sign in to save your progress across devices.', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add(note); + } + } + + confirmResetProgress() { + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive(); + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20); + panel.lineStyle(3, COLORS.danger, 1); + panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20); + const t1 = this.add.text(cx, cy - 92, 'Reset Progress?', { + fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const t2 = this.add.text(cx, cy - 14, 'This clears every opponent you have beaten\nand starts you back at Level 1.', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5).setDepth(D.overlayUI); + const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => { + api.post('/puzzles/bookwork/reset').catch(() => {}); + this.levelsCompleted = 0; + this.showLevelSelect(); + }, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI); + const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(), + { variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI); + this.layer.add([dim, panel, t1, t2, yes, no]); + } + + // ── Intro ─────────────────────────────────────────────────────────────────── + + showIntro(level) { + const lv = this.bank.find((l) => l.level === level); + if (!lv) return; + this.view = 'intro'; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + const opp = this.opponentFor(lv); + + const title = this.add.text(cx, 110, `LEVEL ${level}`, { + fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.layer.add(title); + + this.portraits.push(createOpponentPortrait(this, opp, cx, 360, 150, D.ui, { playIntro: true })); + + const name = this.add.text(cx, 550, opp.name, { fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textHex }).setOrigin(0.5); + const bio = this.add.text(cx, 614, opp.bio ?? '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5); + const tagline = this.add.text(cx, 658, lv.tagline ?? '', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, fontStyle: 'italic', + }).setOrigin(0.5); + + const stars = '★'.repeat(lv.skill) + '☆'.repeat(5 - lv.skill); + const stats = this.add.text(cx, 710, `Skill ${stars} HP ${lv.hp}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, + }).setOrigin(0.5); + + const maxHp = computeMaxHp(this.config, this.levelsCompleted); + const potionNote = isPotionUnlocked(this.config, this.levelsCompleted) ? ' • Potion ready!' : ''; + const you = this.add.text(cx, 756, `Your HP: ${maxHp}${potionNote}`, { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + + const fight = new Button(this, cx - 130, GAME_HEIGHT - 110, 'FIGHT!', () => this.startBattle(level), + { width: 240, height: 66, fontSize: 30 }); + const back = new Button(this, cx + 140, GAME_HEIGHT - 110, 'Back', () => this.showLevelSelect(), + { variant: 'ghost', width: 200, height: 66, fontSize: 24 }); + this.layer.add([name, bio, tagline, stats, you, fight, back]); + } + + // ── Battle ─────────────────────────────────────────────────────────────────── + + startBattle(level) { + const lv = this.bank.find((l) => l.level === level); + if (!lv) return; + this.view = 'battle'; + this.level = level; + this.levelDef = lv; + this.opponent = this.opponentFor(lv); + this.clearLayer(); + + this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted); + this.playerHp = this.playerMaxHp; + this.oppMaxHp = lv.hp; + this.oppHp = lv.hp; + this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted); + this.potionUsed = false; + this.turnPhase = 'player'; + this.grid = makeGrid(); + this.selection = []; + + this.drawBattleUI(); + this.buildTileGrid(); + playSound(this, SFX.UI_ACTIVATE); + } + + drawBattleUI() { + const cx = GAME_WIDTH / 2; + const lv = this.levelDef; + const opp = this.opponent; + + // Header + const hdr = this.add.text(cx, 56, `Level ${this.level} — vs ${opp.name}`, { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(hdr); + + // Dark panels behind portraits + const mkPanel = (x) => { + const g = this.add.graphics().setDepth(D.board); + g.fillStyle(COLORS.panel, 0.9); + g.fillRoundedRect(x - 175, 105, 350, 490, 14); + return g; + }; + this.layer.add([mkPanel(PLAYER_CX), mkPanel(OPP_CX)]); + + // Portrait: player (left) + this.portraits.push(createPlayerPortrait(this, PLAYER_CX, 240, 76, D.ui)); + // Portrait: opponent (right) + this.portraits.push(createOpponentPortrait(this, opp, OPP_CX, 240, 76, D.ui, { playIntro: false })); + + // Player label + HP + this.add.text(PLAYER_CX, 340, 'YOU', { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(D.ui); + this.playerHpBar = this.add.graphics().setDepth(D.ui); + this.playerHpText = this.add.text(PLAYER_CX, 403, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.ui); + + // Opponent label + HP + this.add.text(OPP_CX, 340, opp.name.toUpperCase(), { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(D.ui); + this.oppHpBar = this.add.graphics().setDepth(D.ui); + this.oppHpText = this.add.text(OPP_CX, 403, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex }).setOrigin(0.5).setDepth(D.ui); + + this.updateHpBars(); + + // Potion button (player panel) + if (this.potionUnlocked) { + this.potionBtn = new Button(this, PLAYER_CX, 480, '💊 Potion (+20 HP)', () => this.usePotion(), + { width: 240, height: 46, fontSize: 18 }).setDepth(D.ui); + this.layer.add(this.potionBtn); + } + + // Poison warning (hidden by default) + this.poisonWarning = this.add.text(PLAYER_CX, 530, '', { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#dd99ff', + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(this.poisonWarning); + + // Grid frame + const gf = this.add.graphics().setDepth(D.board); + gf.fillStyle(COLORS.panel, 1); + gf.fillRoundedRect(GRID_X - 12, GRID_Y - 12, GRID_W + 24, GRID_W + 24, 14); + gf.lineStyle(2, COLORS.accent, 0.5); + gf.strokeRoundedRect(GRID_X - 12, GRID_Y - 12, GRID_W + 24, GRID_W + 24, 14); + this.layer.add(gf); + + // Selection line graphics (drawn on top of tiles) + this.selGraphics = this.add.graphics().setDepth(D.selLine); + this.layer.add(this.selGraphics); + + // Word display panel + const wp = this.add.graphics().setDepth(D.board); + wp.fillStyle(COLORS.panel, 0.9); + wp.fillRoundedRect(cx - 280, GRID_Y + GRID_W + 16, 560, 90, 12); + this.layer.add(wp); + + this.wordText = this.add.text(cx, GRID_Y + GRID_W + 44, '', { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + this.statusText = this.add.text(cx, GRID_Y + GRID_W + 82, 'Select adjacent letters to form a word (3+ letters)', { + fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add([this.wordText, this.statusText]); + + // Buttons + this.submitBtn = new Button(this, cx - 140, GRID_Y + GRID_W + 140, 'SUBMIT', () => this.submitWord(), + { width: 220, height: 52, fontSize: 24 }); + const clearBtn = new Button(this, cx + 110, GRID_Y + GRID_W + 140, 'CLEAR', () => { this.clearSelection(); playSound(this, SFX.UI_PICK); }, + { variant: 'ghost', width: 160, height: 52, fontSize: 24 }); + this.layer.add([this.submitBtn, clearBtn]); + + // Back to levels + const back = new Button(this, 90, 56, '← Levels', () => this.showLevelSelect(), + { variant: 'ghost', width: 180, height: 44, fontSize: 18 }); + this.layer.add(back); + } + + // ── Tile grid ──────────────────────────────────────────────────────────────── + + buildTileGrid() { + this.tileObjs = []; + for (let r = 0; r < GRID_SIZE; r++) { + this.tileObjs.push([]); + for (let c = 0; c < GRID_SIZE; c++) { + const x = GRID_X + c * CELL + CELL / 2; + const y = GRID_Y + r * CELL + CELL / 2; + + const bg = this.add.image(x, y, `bw-tile-${this.grid[r][c].type}`).setDepth(D.tiles); + const sel = this.add.image(x, y, 'bw-sel').setDepth(D.selHighlight).setAlpha(0); + const txt = this.add.text(x, y, this.grid[r][c].letter, { + fontFamily: 'Righteous', fontSize: '40px', + color: TILE_COLS[this.grid[r][c].type]?.letter ?? '#2a1a0a', + }).setOrigin(0.5).setDepth(D.letters); + + // Capture r/c for click handler + const zone = this.add.rectangle(x, y, CELL - 4, CELL - 4, 0x000000, 0) + .setDepth(D.letters + 1).setInteractive({ useHandCursor: true }); + zone.on('pointerup', () => { if (this.turnPhase === 'player') this.handleTileClick(r, c); }); + zone.on('pointerover', () => { if (this.turnPhase === 'player') bg.setAlpha(0.85); }); + zone.on('pointerout', () => { bg.setAlpha(1); }); + + this.tileObjs[r].push({ bg, sel, txt, zone }); + this.layer.add([bg, sel, txt, zone]); + } + } + } + + redrawTile(r, c) { + const cell = this.grid[r][c]; + const obj = this.tileObjs[r][c]; + obj.bg.setTexture(`bw-tile-${cell.type}`); + obj.txt.setText(cell.letter); + obj.txt.setColor(TILE_COLS[cell.type]?.letter ?? '#2a1a0a'); + } + + redrawAllTiles() { + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE; c++) this.redrawTile(r, c); + } + } + + // ── Selection ──────────────────────────────────────────────────────────────── + + handleTileClick(r, c) { + const last = this.selection[this.selection.length - 1]; + + // Backtrack: clicking the last selected tile + if (last && last.r === r && last.c === c) { + this.selection.pop(); + playSound(this, SFX.UI_PICK); + this.updateSelectionGraphics(); + this.updateWordDisplay(); + return; + } + + // Already in selection (not last) — ignore + if (this.selection.some((s) => s.r === r && s.c === c)) return; + + // Must be adjacent to the last tile + if (last && !isAdjacent(last, { r, c })) { + // Non-adjacent: clear and restart from this tile + this.clearSelection(); + } + + this.selection.push({ r, c }); + playSound(this, SFX.PIECE_CLICK); + this.updateSelectionGraphics(); + this.updateWordDisplay(); + } + + updateSelectionGraphics() { + // Update sel highlights + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE; c++) { + const inSel = this.selection.some((s) => s.r === r && s.c === c); + this.tileObjs[r][c].sel.setAlpha(inSel ? 1 : 0); + } + } + + // Draw connecting lines between selected tiles + this.selGraphics.clear(); + if (this.selection.length < 2) return; + this.selGraphics.lineStyle(5, 0xfff5a0, 0.7); + const first = this.selection[0]; + const fx = GRID_X + first.c * CELL + CELL / 2; + const fy = GRID_Y + first.r * CELL + CELL / 2; + this.selGraphics.beginPath(); + this.selGraphics.moveTo(fx, fy); + for (let i = 1; i < this.selection.length; i++) { + const s = this.selection[i]; + this.selGraphics.lineTo(GRID_X + s.c * CELL + CELL / 2, GRID_Y + s.r * CELL + CELL / 2); + } + this.selGraphics.strokePath(); + } + + clearSelection() { + this.selection = []; + this.updateSelectionGraphics(); + this.updateWordDisplay(); + } + + updateWordDisplay() { + const word = wordFromCells(this.grid, this.selection); + if (!word) { + this.wordText.setText(''); + this.statusText.setText('Select adjacent letters to form a word (3+ letters)').setColor(COLORS.mutedHex); + return; + } + this.wordText.setText(word); + + if (word.length >= 3) { + const dmg = computeDamage(this.selection, this.grid); + const selfDmg = computeSelfDamage(this.selection, this.grid); + const hasGold = this.selection.some(({ r, c }) => this.grid[r][c].type === 'gold'); + const hasDiamond = this.selection.some(({ r, c }) => this.grid[r][c].type === 'diamond'); + const hasFire = this.selection.some(({ r, c }) => this.grid[r][c].type === 'fire'); + + let tip = `Damage: ${dmg}`; + if (hasGold) tip += ' ✦ Gold tile'; + if (hasDiamond) tip += ' ◆ Diamond tile'; + if (hasFire && selfDmg > 0) tip += ` 🔥 Fire: -${selfDmg} HP!`; + this.statusText.setText(tip).setColor(selfDmg > 0 ? '#ff8866' : COLORS.goldHex); + } else { + this.statusText.setText('Need 3+ letters').setColor(COLORS.mutedHex); + } + } + + // ── Word submission ────────────────────────────────────────────────────────── + + async submitWord() { + if (this.turnPhase !== 'player' || this.selection.length < 3) return; + const word = wordFromCells(this.grid, this.selection); + const cells = this.selection.slice(); + this.turnPhase = 'resolving'; + + let valid = false; + try { + const res = await api.post('/words/scrabble/validate', { words: [word] }); + valid = res?.valid ?? false; + } catch (_) { + this.turnPhase = 'player'; + return; + } + + if (!valid) { + this.shakeWordDisplay(); + this.statusText.setText(`"${word}" is not a valid word`).setColor(COLORS.dangerHex); + this.selection = cells; // restore selection + this.turnPhase = 'player'; + return; + } + + await this.resolveTurn(word, cells); + } + + async resolveTurn(word, cells) { + const dmg = computeDamage(cells, this.grid); + const selfDmg = computeSelfDamage(cells, this.grid); + + // Clear selection visuals before animation starts + this.selection = []; + this.selGraphics?.clear(); + for (let r = 0; r < GRID_SIZE; r++) { + for (let c = 0; c < GRID_SIZE; c++) this.tileObjs[r][c].sel.setAlpha(0); + } + + // Full word animation: fly letters → fireworks → +Damage → opponent HP + await this.animateWord(word, cells, dmg); + + // Self-damage from fire tiles (word < 5 letters) + if (selfDmg > 0) { + this.playerHp = Math.max(0, this.playerHp - selfDmg); + this.floatDamage(PLAYER_CX, 200, `-${selfDmg}`, '#ff6633'); + playSound(this, SFX.SWORD_HIT); + this.updateHpBars(); + await this.delay(350); + } + + this.statusText.setText(`${word} → ${dmg} damage!`).setColor('#aaffaa'); + await this.delay(200); + + if (this.oppHp <= 0) { await this.onVictory(); return; } + + // Opponent attacks + await this.opponentAttack(); + if (this.playerHp <= 0) { await this.onDefeat(); return; } + + // Poison tick (after opponent turn) + const poisonCount = countPoisonTiles(this.grid); + if (poisonCount > 0) { + this.playerHp = Math.max(0, this.playerHp - poisonCount); + this.floatDamage(PLAYER_CX, 200, `-${poisonCount} poison`, '#cc88ff'); + this.updateHpBars(); + await this.delay(350); + if (this.playerHp <= 0) { await this.onDefeat(); return; } + } + + // Refill grid + this.grid = clearAndRefill(this.grid, cells); + await this.animateRefill(cells); + this.redrawAllTiles(); + + const pc = countPoisonTiles(this.grid); + this.poisonWarning.setText(pc > 0 ? `☠ ${pc} poison tile${pc > 1 ? 's' : ''} active` : ''); + + this.wordText.setText(''); + this.statusText.setText('Select adjacent letters to form a word (3+ letters)').setColor(COLORS.mutedHex); + this.turnPhase = 'player'; + } + + // ── Word animation sequence ────────────────────────────────────────────────── + // Phase 1: each letter flies from its tile to its word position at center (0.5s each) + // Phase 2: fireworks burst around the word for 1.5s + // Phase 3: word transforms to "+X Damage" in green (0.4s fade) + // Phase 4: "+X Damage" flies to opponent portrait while shrinking (0.7s) + // Phase 5: opponent HP flashes and reduces + + async animateWord(word, cells, damage) { + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2 - 70; + const FONT_END = 72; + const FONT_START = 36; + const SCALE_END = FONT_END / FONT_START; + + // --- Phase 1: measure each character width at target size --- + const charWidths = word.split('').map((ch) => { + const t = this.add.text(-3000, -3000, ch, { fontFamily: 'Righteous', fontSize: `${FONT_END}px` }); + const w = t.width; + t.destroy(); + return w; + }); + const GAP = 3; + const totalW = charWidths.reduce((s, w) => s + w, 0) + (word.length - 1) * GAP; + let xCur = cx - totalW / 2; + const destPos = charWidths.map((w) => { + const pos = { x: xCur + w / 2, y: cy }; + xCur += w + GAP; + return pos; + }); + + const flyTexts = []; + + for (let i = 0; i < cells.length; i++) { + const { r, c } = cells[i]; + const tileType = this.grid[r][c].type; + + // Hide the source tile + this.tileObjs[r][c].bg.setAlpha(0); + this.tileObjs[r][c].txt.setAlpha(0); + + // Start the flying letter at the tile's screen position + const tileX = GRID_X + c * CELL + CELL / 2; + const tileY = GRID_Y + r * CELL + CELL / 2; + + const flyLetter = this.add.text(tileX, tileY, word[i], { + fontFamily: 'Righteous', + fontSize: `${FONT_START}px`, + color: '#ffffff', + stroke: '#1a0a00', + strokeThickness: 4, + }).setOrigin(0.5).setDepth(D.fx).setScale(1); + this.layer.add(flyLetter); + flyTexts.push(flyLetter); + + playSound(this, i % 2 === 0 ? SFX.SCIFI_PLINK : SFX.SCIFI_PLONK); + + // Tween to its position in the word at center, growing to full size + await new Promise((resolve) => { + this.tweens.add({ + targets: flyLetter, + x: destPos[i].x, + y: destPos[i].y, + scaleX: SCALE_END, + scaleY: SCALE_END, + duration: 500, + ease: 'Cubic.easeInOut', + onComplete: resolve, + }); + }); + } + + // --- Phase 2: fireworks for 1.5 seconds --- + const FW_COLORS = [0xff4444, 0xffff44, 0x44ff88, 0x44aaff, 0xff88ff, 0xffcc44]; + const margin = 70; + const fwPositions = [ + { x: cx - totalW / 2 - margin, y: cy - 55 }, + { x: cx + totalW / 2 + margin, y: cy - 55 }, + { x: cx, y: cy - 110 }, + { x: cx - totalW / 2 - margin / 2, y: cy + 65 }, + { x: cx + totalW / 2 + margin / 2, y: cy + 65 }, + ]; + + fwPositions.forEach(({ x, y }, i) => { + this.time.delayedCall(i * 280, () => { + playSound(this, SFX.FIREWORK); + this.spawnFirework(x, y, FW_COLORS[i % FW_COLORS.length]); + }); + }); + + await this.delay(1500); + + // --- Phase 3: transform word → "+X Damage" in green --- + await new Promise((resolve) => { + this.tweens.add({ + targets: flyTexts, + alpha: 0, + scaleX: 1.3, + scaleY: 1.3, + duration: 250, + ease: 'Cubic.easeOut', + onComplete: resolve, + }); + }); + for (const t of flyTexts) t.destroy(); + + const dmgText = this.add.text(cx, cy, `+${damage} Damage`, { + fontFamily: 'Righteous', + fontSize: '78px', + color: '#44ff88', + stroke: '#0a2010', + strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.fx).setAlpha(0).setScale(0.7); + this.layer.add(dmgText); + + await new Promise((resolve) => { + this.tweens.add({ + targets: dmgText, + alpha: 1, + scaleX: 1, + scaleY: 1, + duration: 400, + ease: 'Back.easeOut', + onComplete: resolve, + }); + }); + + await this.delay(400); + + // --- Phase 4: fly "+X Damage" to opponent portrait --- + const OPP_PORTRAIT_Y = 240; + await new Promise((resolve) => { + this.tweens.add({ + targets: dmgText, + x: OPP_CX, + y: OPP_PORTRAIT_Y, + scaleX: 0.3, + scaleY: 0.3, + alpha: 0.6, + duration: 700, + ease: 'Cubic.easeIn', + onComplete: resolve, + }); + }); + dmgText.destroy(); + + // --- Phase 5: opponent HP flashes and reduces --- + this.oppHp = Math.max(0, this.oppHp - damage); + + playSound(this, SFX.SWORD_SLICE); + const flash = this.add.rectangle(OPP_CX, 310, 360, 430, 0xff2222, 0.45).setDepth(D.fx); + this.layer.add(flash); + await new Promise((resolve) => { + this.tweens.add({ + targets: flash, + alpha: 0, + duration: 550, + ease: 'Cubic.easeOut', + onComplete: () => { flash.destroy(); resolve(); }, + }); + }); + + this.updateHpBars(); + await this.delay(200); + } + + spawnFirework(x, y, tint) { + try { + const em = this.add.particles(x, y, 'bw-fw-spark', { + speed: { min: 140, max: 380 }, + angle: { min: 0, max: 360 }, + lifespan: { min: 500, max: 950 }, + scale: { start: 1.6, end: 0 }, + alpha: { start: 1, end: 0 }, + tint, + blendMode: 'ADD', + quantity: 24, + emitting: false, + }).setDepth(D.fx + 5); + em.explode(24); + this.time.delayedCall(1100, () => { try { em.destroy(); } catch (_) {} }); + } catch (_) { /* particles optional */ } + } + + async animateRefill(usedCells) { + // Briefly hide used tile objects (they now have new content) then fade in + for (const { r, c } of usedCells) { + const obj = this.tileObjs[r][c]; + obj.bg.setAlpha(0); obj.txt.setAlpha(0); + obj.bg.setScale(1); obj.txt.setScale(1); + } + // Also animate tiles that shifted down + const usedCols = [...new Set(usedCells.map(({ c }) => c))]; + for (const c of usedCols) { + for (let r = 0; r < GRID_SIZE; r++) { + const obj = this.tileObjs[r][c]; + this.tweens.add({ + targets: [obj.bg, obj.txt], + alpha: 1, duration: 180, delay: r * 30, ease: 'Cubic.easeOut', + }); + } + } + await this.delay(280); + } + + async opponentAttack() { + const dmg = getAttackDamage(this.levelDef); + const special = getSpecialTile(this.levelDef); + + this.statusText.setText(`${this.opponent.name} attacks!`).setColor(COLORS.dangerHex); + playSound(this, SFX.SWORD_HIT); + this.cameras.main.shake(180, 0.006); + await this.delay(300); + + // Damage text starts at opponent portrait and flies to player portrait + const dmgText = this.add.text(OPP_CX, 490, `+${dmg} Damage`, { + fontFamily: 'Righteous', + fontSize: '52px', + color: '#ff4444', + stroke: '#200000', + strokeThickness: 5, + }).setOrigin(0.5).setDepth(D.fx).setAlpha(0).setScale(0.5); + this.layer.add(dmgText); + + // Pop in at opponent portrait + await new Promise((resolve) => { + this.tweens.add({ + targets: dmgText, + alpha: 1, + scaleX: 1, + scaleY: 1, + duration: 280, + ease: 'Back.easeOut', + onComplete: resolve, + }); + }); + + await this.delay(150); + + // Fly across to player portrait, shrinking as it arrives + await new Promise((resolve) => { + this.tweens.add({ + targets: dmgText, + x: PLAYER_CX, + y: 490, + scaleX: 0.3, + scaleY: 0.3, + alpha: 0.6, + duration: 700, + ease: 'Cubic.easeIn', + onComplete: resolve, + }); + }); + dmgText.destroy(); + + // Apply damage, flash player panel, lower HP bar + this.playerHp = Math.max(0, this.playerHp - dmg); + + const flash = this.add.rectangle(PLAYER_CX, 310, 360, 430, 0xff2222, 0.45).setDepth(D.fx); + this.layer.add(flash); + playSound(this, SFX.SWORD_SLICE); + await new Promise((resolve) => { + this.tweens.add({ + targets: flash, + alpha: 0, + duration: 550, + ease: 'Cubic.easeOut', + onComplete: () => { flash.destroy(); resolve(); }, + }); + }); + + this.updateHpBars(); + await this.delay(200); + + if (special) { + this.grid = dropSpecialTile(this.grid, special); + this.statusText.setText(`${this.opponent.name} dropped a ${special} tile!`) + .setColor(special === 'fire' ? '#ff8844' : '#cc88ff'); + playSound(this, SFX.GEM_DROP); + this.time.delayedCall(120, () => this.redrawAllTiles()); + await this.delay(400); + } + } + + // ── HP bars ───────────────────────────────────────────────────────────────── + + updateHpBars() { + this.drawHpBar(this.playerHpBar, PLAYER_CX, 365, this.playerHp, this.playerMaxHp); + this.playerHpText.setText(`${this.playerHp} / ${this.playerMaxHp}`); + this.drawHpBar(this.oppHpBar, OPP_CX, 365, this.oppHp, this.oppMaxHp); + this.oppHpText.setText(`${this.oppHp} / ${this.oppMaxHp}`); + } + + drawHpBar(g, cx, y, current, max) { + const W = 220, H = 22; + const pct = Math.max(0, current / max); + const filled = Math.round(W * pct); + const barColor = pct > 0.5 ? 0x2ecc71 : pct > 0.25 ? 0xf1c40f : 0xe04444; + + g.clear(); + g.fillStyle(0x1a1208, 1); + g.fillRoundedRect(cx - W / 2, y, W, H, 6); + if (filled > 0) { + g.fillStyle(barColor, 1); + g.fillRoundedRect(cx - W / 2, y, filled, H, 6); + } + g.lineStyle(2, 0x3a3020, 1); + g.strokeRoundedRect(cx - W / 2, y, W, H, 6); + } + + async tweenHpBar(target) { + // Simple immediate redraw + short wait for visual feedback + this.updateHpBars(); + await this.delay(180); + } + + // ── Potion ─────────────────────────────────────────────────────────────────── + + usePotion() { + if (this.potionUsed || this.turnPhase !== 'player') return; + this.potionUsed = true; + this.playerHp = Math.min(this.playerMaxHp, this.playerHp + 20); + this.updateHpBars(); + this.floatDamage(PLAYER_CX, 200, '+20 HP', '#66ffaa'); + playSound(this, SFX.UI_CHIME); + if (this.potionBtn) { + this.potionBtn.setText('💊 Used'); + this.potionBtn.setInteractive(false); + this.potionBtn.setAlpha(0.4); + } + } + + // ── Victory / Defeat ───────────────────────────────────────────────────────── + + async onVictory() { + playSound(this, SFX.VICTORY_SHORT); + this.cameras.main.flash(400, 255, 220, 80, false); + + // Persist progress + if (this.canPersist && this.level === this.levelsCompleted + 1) { + try { + const res = await api.post('/puzzles/bookwork/complete', { level: this.level }); + this.levelsCompleted = res?.levelsCompleted ?? this.levelsCompleted; + } catch (_) {} + } else if (this.level > this.levelsCompleted) { + this.levelsCompleted = this.level; + } + + // Check milestones unlocked by this completion + const newMilestones = (this.config.milestones ?? []).filter( + (m) => m.afterLevel === this.level, + ); + + this.showResultOverlay(true, newMilestones); + } + + async onDefeat() { + playSound(this, SFX.CASINO_LOSE); + this.cameras.main.shake(400, 0.014); + await this.delay(450); + this.showResultOverlay(false, []); + } + + showResultOverlay(won, newMilestones) { + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setDepth(D.overlay).setInteractive(); + + const panel = this.add.graphics().setDepth(D.overlay); + panel.fillStyle(COLORS.panel, 0.97); + panel.fillRoundedRect(cx - 380, cy - 220, 760, won ? 440 + newMilestones.length * 36 : 340, 24); + panel.lineStyle(3, won ? COLORS.gold : COLORS.danger, 1); + panel.strokeRoundedRect(cx - 380, cy - 220, 760, won ? 440 + newMilestones.length * 36 : 340, 24); + + const headline = won ? `Victory!` : `Defeated`; + const color = won ? COLORS.goldHex : COLORS.dangerHex; + const t1 = this.add.text(cx, cy - 150, headline, { + fontFamily: 'Righteous', fontSize: '72px', color, + }).setOrigin(0.5).setDepth(D.overlayUI); + + const sub = won + ? `You defeated ${this.opponent.name}!` + : `${this.opponent.name} was too powerful…`; + const t2 = this.add.text(cx, cy - 56, sub, { + fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + + let yOff = cy + 4; + if (won && newMilestones.length) { + const mLabel = this.add.text(cx, yOff, '— Milestone Unlocked —', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + yOff += 34; + for (const m of newMilestones) { + const parts = []; + if (m.maxHpBonus) parts.push(`+${m.maxHpBonus} Max HP`); + if (m.unlock === 'potion') parts.push('Potion Unlocked'); + const ml = this.add.text(cx, yOff, parts.join(' • '), { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ccffcc', + }).setOrigin(0.5).setDepth(D.overlayUI); + yOff += 30; + this.layer.add([ml]); + } + yOff += 14; + this.layer.add(mLabel); + } + + const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length); + const primaryLabel = won + ? (this.level < this.bank.length ? `Next: Level ${this.level + 1}` : 'All Levels Complete!') + : 'Try Again'; + const primaryAction = won + ? () => (this.level < this.bank.length ? this.showIntro(this.level + 1) : this.showLevelSelect()) + : () => this.startBattle(this.level); + + const btnPrimary = new Button(this, cx - 130, yOff + 60, primaryLabel, primaryAction, + { width: 250, height: 60, fontSize: 24 }).setDepth(D.overlayUI); + const btnLevels = new Button(this, cx + 145, yOff + 60, 'Levels', () => this.showLevelSelect(), + { variant: 'ghost', width: 200, height: 60, fontSize: 24 }).setDepth(D.overlayUI); + + this.layer.add([dim, panel, t1, t2, btnPrimary, btnLevels]); + this.turnPhase = 'over'; + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + floatDamage(x, y, text, color) { + const t = this.add.text(x, y, text, { + fontFamily: 'Righteous', fontSize: '38px', color, + stroke: '#000000', strokeThickness: 4, + }).setOrigin(0.5).setDepth(D.fx); + this.layer.add(t); + this.tweens.add({ + targets: t, y: y - 90, alpha: 0, duration: 900, + ease: 'Cubic.easeOut', onComplete: () => t.destroy(), + }); + } + + shakeWordDisplay() { + playSound(this, SFX.MASTERMIND_DENIED); + const origX = this.wordText.x; + this.tweens.add({ + targets: this.wordText, + x: origX + 12, duration: 60, yoyo: true, repeat: 3, + onComplete: () => { this.wordText.x = origX; }, + }); + } + + delay(ms) { + return new Promise((resolve) => this.time.delayedCall(ms, resolve)); + } +} diff --git a/public/src/games/bookwork/BookworkLogic.js b/public/src/games/bookwork/BookworkLogic.js new file mode 100644 index 0000000..14238d0 --- /dev/null +++ b/public/src/games/bookwork/BookworkLogic.js @@ -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, + ); +} diff --git a/public/src/main.js b/public/src/main.js index eb340f9..a2c408a 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 4b047d1..dfb6f22 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index ae56af4..083078b 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -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 }); diff --git a/public/src/ui/Sounds.js b/public/src/ui/Sounds.js index 3e10d24..82263c0 100644 --- a/public/src/ui/Sounds.js +++ b/public/src/ui/Sounds.js @@ -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) { diff --git a/server/games/registry.js b/server/games/registry.js index 505eb11..c628bcb 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 }); diff --git a/verifyBookwork.js b/verifyBookwork.js new file mode 100644 index 0000000..088351b --- /dev/null +++ b/verifyBookwork.js @@ -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);